From 10702d3ffac1bc3f89a241c0b9750314414cb4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 20:17:11 +0200 Subject: [PATCH 1/3] Factored out common functionality from other PRs --- carta/constants.py | 5 +++ carta/image.py | 102 +++++++++++------------------------------- carta/metadata.py | 69 ++++++++++++++++++++++++++++ carta/session.py | 24 +++++++--- carta/util.py | 4 ++ carta/validation.py | 66 +++++++++++++++++++++++++-- docs/source/carta.rst | 8 ++++ tests/test_image.py | 12 ++--- tests/test_session.py | 4 +- 9 files changed, 201 insertions(+), 93 deletions(-) create mode 100644 carta/metadata.py diff --git a/carta/constants.py b/carta/constants.py index efd4a4a..d97398c 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -141,6 +141,11 @@ class SmoothingMode(IntEnum): GAUSSIAN_BLUR = 2 +class Auto(str, Enum): + """Special value for parameters to be calculated automatically.""" + AUTO = "Auto" + + class ContourDashMode(StrEnum): """Contour dash modes.""" NONE = "None" diff --git a/carta/image.py b/carta/image.py index 41a40fd..aa920fb 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,7 +4,8 @@ """ from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate +from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional +from .metadata import parse_header class Image: @@ -18,8 +19,6 @@ class Image: The session object associated with this image. image_id : integer The ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. - file_name : string - The file name of the image. This is not a full path. Attributes ---------- @@ -27,14 +26,11 @@ class Image: The session object associated with this image. image_id : integer The ID identifying this image within the session. - file_name : string - The file name of the image. """ - def __init__(self, session, image_id, file_name): + def __init__(self, session, image_id): self.session = session self.image_id = image_id - self.file_name = file_name self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) @@ -78,7 +74,7 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(update_directory) image_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, image_id, file_name) + return cls(session, image_id) @classmethod def from_list(cls, session, image_list): @@ -98,7 +94,7 @@ def from_list(cls, session, image_list): list of :obj:`carta.image.Image` A list of new image objects. """ - return [cls(session, f["value"], f["label"].split(":")[1].strip()) for f in image_list] + return [cls(session, f["value"]) for f in image_list] def __repr__(self): return f"{self.session.session_id}:{self.image_id}:{self.file_name}" @@ -163,6 +159,17 @@ def macro(self, target, variable): # METADATA + @property + @cached + def file_name(self): + """The name of the image. + Returns + ------- + string + The image name. + """ + return self.get_value("frameInfo.fileInfo.name") + @property @cached def directory(self): @@ -178,15 +185,7 @@ def directory(self): @property @cached def header(self): - """The header of the image. - - Entries with T or F string values are automatically converted to booleans. - - ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. - - Adjacent ``COMMENT`` entries are not concatenated automatically. - - Any other header entries with no values are given values of ``None``. + """The header of the image, parsed from the raw frontend data (see :obj:`carta.metadata.parse_header`). Returns ------- @@ -194,58 +193,7 @@ def header(self): The header of the image, with field names as keys. """ raw_header = self.get_value("frameInfo.fileInfoExtended.headerEntries") - - header = {} - - history = [] - comment = [] - blank = [] - - def header_value(raw_entry): - try: - return raw_entry["numericValue"] - except KeyError: - try: - value = raw_entry["value"] - if value == 'T': - return True - if value == 'F': - return False - return value - except KeyError: - return None - - for i, raw_entry in enumerate(raw_header): - name = raw_entry["name"] - - if name.startswith("HISTORY "): - line = name[8:] - if line.startswith(">") and history: - history[-1] = history[-1] + line[1:] - else: - history.append(line) - continue - - if name.startswith("COMMENT "): - comment.append(name[8:]) - continue - - if name.startswith(" " * 8): - blank.append(name[8:]) - continue - - header[name] = header_value(raw_entry) - - if history: - header["HISTORY"] = history - - if comment: - header["COMMENT"] = comment - - if blank: - header[""] = blank - - return header + return parse_header(raw_header) @property @cached @@ -607,7 +555,7 @@ def hide_raster(self): # CONTOURS - @validate(NoneOr(IterableOf(Number())), NoneOr(Constant(SmoothingMode)), NoneOr(Number())) + @validate(*all_optional(IterableOf(Number()), Constant(SmoothingMode), Number())) def configure_contours(self, levels=None, smoothing_mode=None, smoothing_factor=None): """Configure contours. @@ -628,7 +576,7 @@ def configure_contours(self, levels=None, smoothing_mode=None, smoothing_factor= smoothing_factor = self.macro("contourConfig", "smoothingFactor") self.call_action("contourConfig.setContourConfiguration", levels, smoothing_mode, smoothing_factor) - @validate(NoneOr(Constant(ContourDashMode)), NoneOr(Number())) + @validate(*all_optional(Constant(ContourDashMode), Number())) def set_contour_dash(self, dash_mode=None, thickness=None): """Set the contour dash style. @@ -653,7 +601,7 @@ def set_contour_color(self, color): Parameters ---------- color : {0} - The color. + The color. The default is green. """ self.call_action("contourConfig.setColor", color) self.call_action("contourConfig.setColormapEnabled", False) @@ -669,7 +617,7 @@ def set_contour_colormap(self, colormap, bias=None, contrast=None): colormap : {0} The colormap. bias : {1} - The colormap bias. + The colormap bias. The default is :obj:`carta.constants.Colormap.VIRIDIS`. contrast : {2} The colormap contrast. """ @@ -684,7 +632,7 @@ def apply_contours(self): """Apply the contour configuration.""" self.call_action("applyContours") - @validate(NoneOr(IterableOf(Number())), NoneOr(Constant(SmoothingMode)), NoneOr(Number()), NoneOr(Constant(ContourDashMode)), NoneOr(Number()), NoneOr(Color()), NoneOr(Constant(Colormap)), NoneOr(Number()), NoneOr(Number())) + @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. @@ -703,9 +651,9 @@ def plot_contours(self, levels=None, smoothing_mode=None, smoothing_factor=None, thickness : {4} The dash thickness. color : {5} - The color. + The color. The default is green. colormap : {6} - The colormap. + The colormap. The default is :obj:`carta.constants.Colormap.VIRIDIS`. bias : {7} The colormap bias. contrast : {8} diff --git a/carta/metadata.py b/carta/metadata.py new file mode 100644 index 0000000..af5779b --- /dev/null +++ b/carta/metadata.py @@ -0,0 +1,69 @@ +"""This module provides a collection of helper objects for storing and accessing file metadata.""" + + +def parse_header(raw_header): + """Parse raw image header entries from the frontend into a more user-friendly format. + Entries with T or F string values are automatically converted to booleans. + ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. + Adjacent ``COMMENT`` entries are not concatenated automatically. + Any other header entries with no values are given values of ``None``. + Parameters + ---------- + raw_header : dict + The raw header entries received from the frontend. + Returns + ------- + dict of string to string, integer, float, boolean, ``None`` or list of strings + The header of the image, with field names as keys. + """ + header = {} + + history = [] + comment = [] + blank = [] + + def header_value(raw_entry): + try: + return raw_entry["numericValue"] + except KeyError: + try: + value = raw_entry["value"] + if value == 'T': + return True + if value == 'F': + return False + return value + except KeyError: + return None + + for i, raw_entry in enumerate(raw_header): + name = raw_entry["name"] + + if name.startswith("HISTORY "): + line = name[8:] + if line.startswith(">") and history: + history[-1] = history[-1] + line[1:] + else: + history.append(line) + continue + + if name.startswith("COMMENT "): + comment.append(name[8:]) + continue + + if name.startswith(" " * 8): + blank.append(name[8:]) + continue + + header[name] = header_value(raw_entry) + + if history: + header["HISTORY"] = history + + if comment: + header["COMMENT"] = comment + + if blank: + header[""] = blank + + return header diff --git a/carta/session.py b/carta/session.py index fa8ee60..1b0fde6 100644 --- a/carta/session.py +++ b/carta/session.py @@ -356,6 +356,11 @@ def open_image(self, path, hdu="", append=False, make_active=True, update_direct Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the parent directory of the image. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ directory, file_name = posixpath.split(path) return Image.new(self, directory, file_name, hdu, append, False, make_active=make_active, update_directory=update_directory) @@ -376,6 +381,11 @@ def open_complex_image(self, path, component=ComplexComponent.AMPLITUDE, append= Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the parent directory of the image. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ directory, file_name = posixpath.split(path) expression = f'{component}("{file_name}")' @@ -397,6 +407,11 @@ def open_LEL_image(self, expression, directory=".", append=False, make_active=Tr Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the base directory of the LEL expression. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ return Image.new(self, directory, expression, "", append, True, make_active=make_active, update_directory=update_directory) @@ -405,7 +420,8 @@ def image_list(self): Returns ------- - list of :obj:`carta.image.Image` objects. + list of :obj:`carta.image.Image` objects + The list of images open in this session. """ return Image.from_list(self, self.get_value("frameNames")) @@ -417,10 +433,8 @@ def active_frame(self): :obj:`carta.image.Image` The currently active image. """ - frame_info = self.get_value("activeFrame.frameInfo") - image_id = frame_info["fileId"] - file_name = frame_info["fileInfo"]["name"] - return Image(self, image_id, file_name) + image_id = self.get_value("activeFrame.frameInfo.fileId") + return Image(self, image_id) def clear_spatial_reference(self): """Clear the spatial reference.""" diff --git a/carta/util.py b/carta/util.py index a213266..dcf7410 100644 --- a/carta/util.py +++ b/carta/util.py @@ -96,6 +96,10 @@ def json(self): return {"macroTarget": self.target, "macroVariable": self.variable} +Macro.UNDEFINED = Macro("", "undefined") +Macro.UNDEFINED.__doc__ = """A :obj:`carta.util.Macro` instance which is deserialized as ``undefined`` by the frontend.""" + + class CartaEncoder(json.JSONEncoder): """A custom encoder to JSON which correctly serialises custom objects with a ``json`` method, and numpy arrays.""" diff --git a/carta/validation.py b/carta/validation.py index c6464d9..a41cba6 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -444,7 +444,7 @@ def description(self): class NoneOr(Union): """A union of other parameter descriptors as well as ``None``. - In the most common use case, this is used with a single other parameter type for optional parameters which are ``None`` by default. In more complex cases this can be used as shorthand in place of a :obj:`carta.validation.Union` with an explicit :obj:`carta.validation.NoneParameter` option. + In the most common use case, this is used with a single other parameter type for optional parameters which are ``None`` by default. In more complex cases this can be used as shorthand in place of a :obj:`carta.validation.Union` with an explicit :obj:`carta.validation.NoneParameter` option. Also see :obj:`carta.validation.all_optional` for a less verbose way to specify multiple sequential optional parameters. Parameters ---------- @@ -538,6 +538,49 @@ def description(self): return f"an iterable {size_desc}in which each element is {self.param.description}" +class MapOf(IterableOf): + """A dictionary of keys and values which must match the given descriptors. + Parameters + ---------- + value_param : :obj:`carta.validation.Parameter` + The value parameter descriptor. + Attributes + ---------- + value_param : :obj:`carta.validation.Parameter` + The value parameter descriptor. + """ + + def __init__(self, key_param, value_param, min_size=None, max_size=None): + self.value_param = value_param + super().__init__(key_param, min_size, max_size) + + def validate(self, value, parent): + """Check if each element of the iterable can be validated with the given descriptor. + See :obj:`carta.validation.Parameter.validate` for general information about this method. + """ + + try: + for v in value.values(): + self.value_param.validate(v, parent) + except AttributeError as e: + if str(e).endswith("has no attribute 'values'"): + raise ValueError(f"{value} is not a dictionary, but {self.description} was expected.") + raise e + + super().validate(value, parent) + + @property + def description(self): + """A human-readable description of this parameter descriptor. + Returns + ------- + string + The description. + """ + + return re.sub("^an iterable (.*?)in which each element is (.*)$", rf"a dictionary \1in which each key is {self.param.description} and each value is {self.value_param.description}", super().description) + + COLORNAMES = ('aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgrey', 'darkgreen', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'grey', 'green', 'greenyellow', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgrey', 'lightgreen', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen') @@ -799,13 +842,13 @@ def newfunc(self, *args, **kwargs): param = kwvargs[key] param.validate(value, self) except KeyError: - raise CartaValidationFailed(f"Unexpected keyword parameter: {key}") + raise CartaValidationFailed(f"Unexpected keyword parameter passed to {func.__name__}: {key}") except (TypeError, ValueError, AttributeError) as e: # Strip out any documentation formatting from the descriptions msg = str(e) msg = STRIP_OBJ.sub(r"\1", msg) msg = STRIP_CODE.sub(r"\1", msg) - raise CartaValidationFailed(f"Invalid function parameter: {msg}") + raise CartaValidationFailed(f"Invalid function parameter passed to {func.__name__}: {msg}") return func(self, *args, **kwargs) # If descriptions contain formatting they are not formatted correctly by Sphinx @@ -822,5 +865,22 @@ def fix_description(s): if newfunc.__doc__ is not None: newfunc.__doc__ = newfunc.__doc__.format(*(fix_description(p.description) for p in vargs)) + # Add a handle to the validation parameters to allow functions which call other functions to reuse parameters + newfunc.VARGS = vargs + return newfunc return decorator + + +def all_optional(*vargs): + """Wrapper to make all parameters in an iterable optional. + For improved legibility in functions with many sequential optional parameters. Can also enable reuse of validation parameters in functions which call other functions. + Parameters + ---------- + *vargs : iterable of :obj:`carta.validation.Parameter` objects + Returns + ------- + iterable of :obj:`carta.validation.Parameter` objects + 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) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 2b6cee9..e5805a2 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -33,6 +33,14 @@ carta.image module :undoc-members: :show-inheritance: +carta.metadata module +------------------ + +.. automodule:: carta.metadata + :members: + :undoc-members: + :show-inheritance: + carta.protocol module --------------------- diff --git a/tests/test_image.py b/tests/test_image.py index f89631b..1d0b11d 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -22,7 +22,7 @@ def session(): def image(session): """Return an image object which uses the session fixture. """ - return Image(session, 0, "") + return Image(session, 0) @pytest.fixture @@ -47,7 +47,7 @@ def mock_session_call_action(session, mocker): def mock_property(mocker): """Return a helper function to mock the value of a decorated image property using a simple syntax.""" def func(property_name, mock_value): - mocker.patch(f"carta.image.Image.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) + return mocker.patch(f"carta.image.Image.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) return func @@ -55,7 +55,7 @@ def func(property_name, mock_value): def mock_method(image, mocker): """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(image, method_name, side_effect=return_values) + return mocker.patch.object(image, method_name, side_effect=return_values) return func @@ -63,7 +63,7 @@ def func(method_name, return_values): def mock_session_method(session, mocker): """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(session, method_name, side_effect=return_values) + return mocker.patch.object(session, method_name, side_effect=return_values) return func # TESTS @@ -127,15 +127,15 @@ def test_new(session, mock_session_call_action, mock_session_method, args, kwarg mock_session_call_action.assert_called_with(*expected_params, return_path='frameInfo.fileId') - assert type(image_object) == Image + assert type(image_object) is Image assert image_object.session == session assert image_object.image_id == 123 - assert image_object.file_name == expected_params[2] # SIMPLE PROPERTIES TODO to be completed. @pytest.mark.parametrize("property_name,expected_path", [ + ("file_name", "frameInfo.fileInfo.name"), ("directory", "frameInfo.directory"), ("width", "frameInfo.fileInfoExtended.width"), ]) diff --git a/tests/test_session.py b/tests/test_session.py index 01b5bbb..b0d1c85 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -34,7 +34,7 @@ def mock_call_action(session, mocker): def mock_property(mocker): """Return a helper function to mock the value of a decorated session property using a simple syntax.""" def func(property_name, mock_value): - mocker.patch(f"carta.session.Session.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) + return mocker.patch(f"carta.session.Session.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) return func @@ -42,7 +42,7 @@ def func(property_name, mock_value): def mock_method(session, mocker): """Return a helper function to mock the return value(s) of an session method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(session, method_name, side_effect=return_values) + return mocker.patch.object(session, method_name, side_effect=return_values) return func From ca7c89b90b0c04703d324e404f4636da6069f177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 20:34:24 +0200 Subject: [PATCH 2/3] move units out of util and into their own module --- carta/image.py | 3 +- carta/units.py | 428 ++++++++++++++++++++++++++ carta/util.py | 425 ------------------------- carta/validation.py | 3 +- tests/{test_util.py => test_units.py} | 2 +- 5 files changed, 433 insertions(+), 428 deletions(-) create mode 100644 carta/units.py rename tests/{test_util.py => test_units.py} (98%) diff --git a/carta/image.py b/carta/image.py index aa920fb..9b3ed8c 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,7 +3,8 @@ 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, CoordinateSystem, SpatialAxis -from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate +from .util import Macro, cached +from .units import PixelValue, AngularSize, WorldCoordinate from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional from .metadata import parse_header diff --git a/carta/units.py b/carta/units.py new file mode 100644 index 0000000..7e908df --- /dev/null +++ b/carta/units.py @@ -0,0 +1,428 @@ +"""This module provides helper objects for unit conversion.""" + +import re +import math + +from .constants import NumberFormat, SpatialAxis + + +class PixelValue: + """Parses pixel values.""" + + UNITS = {"px", "pix", "pixel", "pixels"} + UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*(?:{'|'.join(UNITS)})$" + + @classmethod + def valid(cls, value): + """Whether the input string is a numeric value followed by a pixel unit. + + Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. Pixel values may be negative. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is a pixel value. + """ + m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) + return m is not None + + @classmethod + def as_float(cls, value): + """Parse a string containing a numeric value followed by a pixel unit, and return the numeric part as a float. + + Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. + + Parameters + ---------- + value : string + The string representation of the pixel value. + + Returns + ------- + float + The numeric portion of the pixel value. + + Raises + ------ + ValueError + If the input string is not in a recognized format. + """ + m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) + if m is None: + raise ValueError(f"{repr(value)} is not in a recognized pixel format.") + return float(m.group(1)) + + +class AngularSize: + """An angular size. + + This class provides methods for parsing angular sizes with any known unit, and should not be instantiated directly. Child classes can be used directly if the unit is known. + + Child class instances have a string representation in a normalized format which can be parsed by the frontend. + """ + FORMATS = {} + NAME = "angular size" + + def __init__(self, value): + self.value = value + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._update_unit_regex(cls.INPUT_UNITS) + + for unit in cls.INPUT_UNITS: + AngularSize.FORMATS[unit] = cls + + AngularSize._update_unit_regex(AngularSize.FORMATS.keys()) + + @classmethod + def _update_unit_regex(cls, units): + """Update the unit regexes using the provided unit set.""" + symbols = {u for u in units if len(u) <= 1} + words = units - symbols + + cls.SYMBOL_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)({'|'.join(symbols)})$" + cls.WORD_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)\s*({'|'.join(words)})$" + + @classmethod + def valid(cls, value): + """Whether the input string is a numeric value followed by an angular size unit. + + A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. + + This method may also be used from child classes if a specific format is required. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is an angular size. + """ + return any((re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE), re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE))) + + @classmethod + def from_string(cls, value): + """Construct an angular size object from a string. + + A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. + + This method may also be used from child classes if a specific format is required. + + Parameters + ---------- + value : string + The string representation of the angular size. + + Returns + ------- + :obj:`carta.util.AngularSize` + The angular size object. + + Raises + ------ + ValueError + If the angular size string is not in a recognized format. + """ + m = re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE) + if m is None: + m = re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE) + if m is None: + raise ValueError(f"{repr(value)} is not in a recognized {cls.NAME} format.") + value, unit = m.groups() + if cls is AngularSize: + return cls.FORMATS[unit](float(value)) + return cls(float(value)) + + def __str__(self): + if type(self) is AngularSize: + raise NotImplementedError() + value = self.value * self.FACTOR + return f"{value:g}{self.OUTPUT_UNIT}" + + +class DegreesSize(AngularSize): + """An angular size in degrees.""" + NAME = "degree" + INPUT_UNITS = {"deg", "degree", "degrees"} + OUTPUT_UNIT = "deg" + FACTOR = 1 + + +class ArcminSize(AngularSize): + """An angular size in arcminutes.""" + NAME = "arcminute" + INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} + OUTPUT_UNIT = "'" + FACTOR = 1 + + +class ArcsecSize(AngularSize): + """An angular size in arcseconds.""" + NAME = "arcsecond" + INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} + OUTPUT_UNIT = "\"" + FACTOR = 1 + + +class MilliarcsecSize(AngularSize): + """An angular size in milliarcseconds.""" + NAME = "milliarcsecond" + INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} + OUTPUT_UNIT = "\"" + FACTOR = 1e-3 + + +class MicroarcsecSize(AngularSize): + """An angular size in microarcseconds.""" + NAME = "microarcsecond" + INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} + OUTPUT_UNIT = "\"" + FACTOR = 1e-6 + + +class WorldCoordinate: + """A world coordinate.""" + + FMT = None + FORMATS = {} + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses corresponding to number formats.""" + super().__init_subclass__(**kwargs) + if isinstance(cls.FMT, NumberFormat): + super(cls, cls).FORMATS[cls.FMT] = cls + + @classmethod + def valid(cls, value): + """Whether the input string is a world coordinate string in any of the recognised formats. + + Coordinates may be provided in HMS or DMS format (with colons or letters as separators), or in degrees (with or without an explicit unit). Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is a valid world coordinate. + """ + if cls is WorldCoordinate: + return any(fmt.valid(value) for fmt in cls.FORMATS.values()) + return any(re.match(exp, value, re.IGNORECASE) for exp in cls.REGEX.values()) + + @classmethod + def with_format(cls, fmt): + """Return the subclass of :obj:`carta.util.WorldCoordinate` corresponding to the specified format.""" + if isinstance(fmt, NumberFormat): + return cls.FORMATS[fmt] + raise ValueError(f"Unknown number format: {fmt}") + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object from a string. + + This is implemented in subclasses corresponding to different formats. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.WorldCoordinate` + The coordinate object. + """ + raise NotImplementedError() + + +class DegreesCoordinate(WorldCoordinate): + """A world coordinate in decimal degree format.""" + FMT = NumberFormat.DEGREES + DEGREE_UNITS = DegreesSize.INPUT_UNITS + REGEX = { + "DEGREE_UNIT": rf"^-?(\d+(?:\.\d+)?)\s*({'|'.join(DEGREE_UNITS)})$", + "DECIMAL": r"^-?\d+(\.\d+)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in decimal degree format from a string. + + Coordinates may be provided with or without an explicit unit. Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.DegreesCoordinate` + The coordinate object. + """ + m = re.match(cls.REGEX["DECIMAL"], value, re.IGNORECASE) + if m is not None: + fvalue = float(value) + else: + m = re.match(cls.REGEX["DEGREE_UNIT"], value, re.IGNORECASE) + if m is not None: + fvalue = float(m.group(1)) + else: + raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") + + if axis == SpatialAxis.X and not 0 <= fvalue < 360: + raise ValueError(f"Degrees coordinate string {value} is outside the permitted longitude range [0, 360).") + if axis == SpatialAxis.Y and not -90 <= fvalue <= 90: + raise ValueError(f"Degrees coordinate string {value} is outside the permitted latitude range [-90, 90].") + + return cls(fvalue) + + def __init__(self, degrees): + self.degrees = degrees + + def __str__(self): + return f"{self.degrees:g}" + + +class SexagesimalCoordinate(WorldCoordinate): + """A world coordinate in sexagesimal format. + + This class contains common functionality for parsing the HMS and DMS formats. + """ + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in sexagesimal format from a string. + + Coordinates may be provided in HMS or DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.SexagesimalCoordinate` + The coordinate object. + """ + def to_float(strs): + return tuple(0 if s is None else float(s) for s in strs) + + m = re.match(cls.REGEX["COLON"], value, re.IGNORECASE) + if m is not None: + return cls(*to_float(m.groups())) + + m = re.match(cls.REGEX["LETTER"], value, re.IGNORECASE) + if m is not None: + return cls(*to_float(m.groups())) + + raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") + + def __init__(self, hours_or_degrees, minutes, seconds): + self.hours_or_degrees = hours_or_degrees + self.minutes = minutes + self.seconds = seconds + + def __str__(self): + fractional_seconds, whole_seconds = math.modf(self.seconds) + fraction_string = f"{fractional_seconds:g}".lstrip("0") if fractional_seconds else "" + return f"{self.hours_or_degrees:g}:{self.minutes:0>2.0f}:{whole_seconds:0>2.0f}{fraction_string}" + + def as_tuple(self): + return self.hours_or_degrees, self.minutes, self.seconds + + +class HMSCoordinate(SexagesimalCoordinate): + """A world coordinate in HMS format.""" + FMT = NumberFormat.HMS + # Temporarily allow negative H values to account for frontend custom format oddity + REGEX = { + "COLON": r"^(-?(?:\d|[01]\d|2[0-3]))?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", + "LETTER": r"^(?:(-?(?:\d|[01]\d|2[0-3]))h)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in HMS format from a string. + + Coordinates may be provided in HMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.HMSCoordinate` + The coordinate object. + """ + H, M, S = super().from_string(value, axis).as_tuple() + + if axis == SpatialAxis.X and not 0 <= H < 24: + raise ValueError(f"HMS coordinate string {value} is outside the permitted longitude range [0:00:00, 24:00:00).") + + if axis == SpatialAxis.Y: # Temporary; we can make this whole option invalid + if H < -6 or H > 6 or ((H in (-6, 6)) and (M or S)): + raise ValueError(f"HMS coordinate string {value} is outside the permitted latitude range [-6:00:00, 6:00:00].") + + return cls(H, M, S) + + +class DMSCoordinate(SexagesimalCoordinate): + """A world coordinate in DMS format.""" + FMT = NumberFormat.DMS + REGEX = { + "COLON": r"^(-?\d+)?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", + "LETTER": r"^(?:(-?\d+)d)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in DMS format from a string. + + Coordinates may be provided in DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.DMSCoordinate` + The coordinate object. + """ + D, M, S = super().from_string(value, axis).as_tuple() + + if axis == SpatialAxis.X and not 0 <= D < 360: + raise ValueError(f"DMS coordinate string {value} is outside the permitted longitude range [0:00:00, 360:00:00).") + + if axis == SpatialAxis.Y: + if D < -90 or D > 90 or ((D in (-90, 90)) and (M or S)): + raise ValueError(f"DMS coordinate string {value} is outside the permitted latitude range [-90:00:00, 90:00:00].") + + return cls(D, M, S) diff --git a/carta/util.py b/carta/util.py index dcf7410..d2e5053 100644 --- a/carta/util.py +++ b/carta/util.py @@ -4,9 +4,6 @@ import json import functools import re -import math - -from .constants import NumberFormat, SpatialAxis logger = logging.getLogger("carta_scripting") logger.setLevel(logging.WARN) @@ -139,425 +136,3 @@ def split_action_path(path): """ parts = path.split('.') return '.'.join(parts[:-1]), parts[-1] - - -class PixelValue: - """Parses pixel values.""" - - UNITS = {"px", "pix", "pixel", "pixels"} - UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*(?:{'|'.join(UNITS)})$" - - @classmethod - def valid(cls, value): - """Whether the input string is a numeric value followed by a pixel unit. - - Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. Pixel values may be negative. - - Parameters - ---------- - value : string - The input string. - - Returns - ------- - boolean - Whether the input string is a pixel value. - """ - m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) - return m is not None - - @classmethod - def as_float(cls, value): - """Parse a string containing a numeric value followed by a pixel unit, and return the numeric part as a float. - - Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. - - Parameters - ---------- - value : string - The string representation of the pixel value. - - Returns - ------- - float - The numeric portion of the pixel value. - - Raises - ------ - ValueError - If the input string is not in a recognized format. - """ - m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) - if m is None: - raise ValueError(f"{repr(value)} is not in a recognized pixel format.") - return float(m.group(1)) - - -class AngularSize: - """An angular size. - - This class provides methods for parsing angular sizes with any known unit, and should not be instantiated directly. Child classes can be used directly if the unit is known. - - Child class instances have a string representation in a normalized format which can be parsed by the frontend. - """ - FORMATS = {} - NAME = "angular size" - - def __init__(self, value): - self.value = value - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - cls._update_unit_regex(cls.INPUT_UNITS) - - for unit in cls.INPUT_UNITS: - AngularSize.FORMATS[unit] = cls - - AngularSize._update_unit_regex(AngularSize.FORMATS.keys()) - - @classmethod - def _update_unit_regex(cls, units): - """Update the unit regexes using the provided unit set.""" - symbols = {u for u in units if len(u) <= 1} - words = units - symbols - - cls.SYMBOL_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)({'|'.join(symbols)})$" - cls.WORD_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)\s*({'|'.join(words)})$" - - @classmethod - def valid(cls, value): - """Whether the input string is a numeric value followed by an angular size unit. - - A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. - - This method may also be used from child classes if a specific format is required. - - Parameters - ---------- - value : string - The input string. - - Returns - ------- - boolean - Whether the input string is an angular size. - """ - return any((re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE), re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE))) - - @classmethod - def from_string(cls, value): - """Construct an angular size object from a string. - - A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. - - This method may also be used from child classes if a specific format is required. - - Parameters - ---------- - value : string - The string representation of the angular size. - - Returns - ------- - :obj:`carta.util.AngularSize` - The angular size object. - - Raises - ------ - ValueError - If the angular size string is not in a recognized format. - """ - m = re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE) - if m is None: - m = re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE) - if m is None: - raise ValueError(f"{repr(value)} is not in a recognized {cls.NAME} format.") - value, unit = m.groups() - if cls is AngularSize: - return cls.FORMATS[unit](float(value)) - return cls(float(value)) - - def __str__(self): - if type(self) is AngularSize: - raise NotImplementedError() - value = self.value * self.FACTOR - return f"{value:g}{self.OUTPUT_UNIT}" - - -class DegreesSize(AngularSize): - """An angular size in degrees.""" - NAME = "degree" - INPUT_UNITS = {"deg", "degree", "degrees"} - OUTPUT_UNIT = "deg" - FACTOR = 1 - - -class ArcminSize(AngularSize): - """An angular size in arcminutes.""" - NAME = "arcminute" - INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} - OUTPUT_UNIT = "'" - FACTOR = 1 - - -class ArcsecSize(AngularSize): - """An angular size in arcseconds.""" - NAME = "arcsecond" - INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} - OUTPUT_UNIT = "\"" - FACTOR = 1 - - -class MilliarcsecSize(AngularSize): - """An angular size in milliarcseconds.""" - NAME = "milliarcsecond" - INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} - OUTPUT_UNIT = "\"" - FACTOR = 1e-3 - - -class MicroarcsecSize(AngularSize): - """An angular size in microarcseconds.""" - NAME = "microarcsecond" - INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} - OUTPUT_UNIT = "\"" - FACTOR = 1e-6 - - -class WorldCoordinate: - """A world coordinate.""" - - FMT = None - FORMATS = {} - - def __init_subclass__(cls, **kwargs): - """Automatically register subclasses corresponding to number formats.""" - super().__init_subclass__(**kwargs) - if isinstance(cls.FMT, NumberFormat): - super(cls, cls).FORMATS[cls.FMT] = cls - - @classmethod - def valid(cls, value): - """Whether the input string is a world coordinate string in any of the recognised formats. - - Coordinates may be provided in HMS or DMS format (with colons or letters as separators), or in degrees (with or without an explicit unit). Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. - - Parameters - ---------- - value : string - The input string. - - Returns - ------- - boolean - Whether the input string is a valid world coordinate. - """ - if cls is WorldCoordinate: - return any(fmt.valid(value) for fmt in cls.FORMATS.values()) - return any(re.match(exp, value, re.IGNORECASE) for exp in cls.REGEX.values()) - - @classmethod - def with_format(cls, fmt): - """Return the subclass of :obj:`carta.util.WorldCoordinate` corresponding to the specified format.""" - if isinstance(fmt, NumberFormat): - return cls.FORMATS[fmt] - raise ValueError(f"Unknown number format: {fmt}") - - @classmethod - def from_string(cls, value, axis): - """Construct a world coordinate object from a string. - - This is implemented in subclasses corresponding to different formats. - - Parameters - ---------- - value : string - The input string. - axis : :obj:`carta.constants.SpatialAxis` - The spatial axis of this coordinate. - - Returns - ------- - :obj:`carta.util.WorldCoordinate` - The coordinate object. - """ - raise NotImplementedError() - - -class DegreesCoordinate(WorldCoordinate): - """A world coordinate in decimal degree format.""" - FMT = NumberFormat.DEGREES - DEGREE_UNITS = DegreesSize.INPUT_UNITS - REGEX = { - "DEGREE_UNIT": rf"^-?(\d+(?:\.\d+)?)\s*({'|'.join(DEGREE_UNITS)})$", - "DECIMAL": r"^-?\d+(\.\d+)?$", - } - - @classmethod - def from_string(cls, value, axis): - """Construct a world coordinate object in decimal degree format from a string. - - Coordinates may be provided with or without an explicit unit. Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. - - Parameters - ---------- - value : string - The input string. - axis : :obj:`carta.constants.SpatialAxis` - The spatial axis of this coordinate. - - Returns - ------- - :obj:`carta.util.DegreesCoordinate` - The coordinate object. - """ - m = re.match(cls.REGEX["DECIMAL"], value, re.IGNORECASE) - if m is not None: - fvalue = float(value) - else: - m = re.match(cls.REGEX["DEGREE_UNIT"], value, re.IGNORECASE) - if m is not None: - fvalue = float(m.group(1)) - else: - raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") - - if axis == SpatialAxis.X and not 0 <= fvalue < 360: - raise ValueError(f"Degrees coordinate string {value} is outside the permitted longitude range [0, 360).") - if axis == SpatialAxis.Y and not -90 <= fvalue <= 90: - raise ValueError(f"Degrees coordinate string {value} is outside the permitted latitude range [-90, 90].") - - return cls(fvalue) - - def __init__(self, degrees): - self.degrees = degrees - - def __str__(self): - return f"{self.degrees:g}" - - -class SexagesimalCoordinate(WorldCoordinate): - """A world coordinate in sexagesimal format. - - This class contains common functionality for parsing the HMS and DMS formats. - """ - - @classmethod - def from_string(cls, value, axis): - """Construct a world coordinate object in sexagesimal format from a string. - - Coordinates may be provided in HMS or DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. - - Parameters - ---------- - value : string - The input string. - axis : :obj:`carta.constants.SpatialAxis` - The spatial axis of this coordinate. - - Returns - ------- - :obj:`carta.util.SexagesimalCoordinate` - The coordinate object. - """ - def to_float(strs): - return tuple(0 if s is None else float(s) for s in strs) - - m = re.match(cls.REGEX["COLON"], value, re.IGNORECASE) - if m is not None: - return cls(*to_float(m.groups())) - - m = re.match(cls.REGEX["LETTER"], value, re.IGNORECASE) - if m is not None: - return cls(*to_float(m.groups())) - - raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") - - def __init__(self, hours_or_degrees, minutes, seconds): - self.hours_or_degrees = hours_or_degrees - self.minutes = minutes - self.seconds = seconds - - def __str__(self): - fractional_seconds, whole_seconds = math.modf(self.seconds) - fraction_string = f"{fractional_seconds:g}".lstrip("0") if fractional_seconds else "" - return f"{self.hours_or_degrees:g}:{self.minutes:0>2.0f}:{whole_seconds:0>2.0f}{fraction_string}" - - def as_tuple(self): - return self.hours_or_degrees, self.minutes, self.seconds - - -class HMSCoordinate(SexagesimalCoordinate): - """A world coordinate in HMS format.""" - FMT = NumberFormat.HMS - # Temporarily allow negative H values to account for frontend custom format oddity - REGEX = { - "COLON": r"^(-?(?:\d|[01]\d|2[0-3]))?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", - "LETTER": r"^(?:(-?(?:\d|[01]\d|2[0-3]))h)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", - } - - @classmethod - def from_string(cls, value, axis): - """Construct a world coordinate object in HMS format from a string. - - Coordinates may be provided in HMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. - - Parameters - ---------- - value : string - The input string. - axis : :obj:`carta.constants.SpatialAxis` - The spatial axis of this coordinate. - - Returns - ------- - :obj:`carta.util.HMSCoordinate` - The coordinate object. - """ - H, M, S = super().from_string(value, axis).as_tuple() - - if axis == SpatialAxis.X and not 0 <= H < 24: - raise ValueError(f"HMS coordinate string {value} is outside the permitted longitude range [0:00:00, 24:00:00).") - - if axis == SpatialAxis.Y: # Temporary; we can make this whole option invalid - if H < -6 or H > 6 or ((H in (-6, 6)) and (M or S)): - raise ValueError(f"HMS coordinate string {value} is outside the permitted latitude range [-6:00:00, 6:00:00].") - - return cls(H, M, S) - - -class DMSCoordinate(SexagesimalCoordinate): - """A world coordinate in DMS format.""" - FMT = NumberFormat.DMS - REGEX = { - "COLON": r"^(-?\d+)?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", - "LETTER": r"^(?:(-?\d+)d)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", - } - - @classmethod - def from_string(cls, value, axis): - """Construct a world coordinate object in DMS format from a string. - - Coordinates may be provided in DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. - - Parameters - ---------- - value : string - The input string. - axis : :obj:`carta.constants.SpatialAxis` - The spatial axis of this coordinate. - - Returns - ------- - :obj:`carta.util.DMSCoordinate` - The coordinate object. - """ - D, M, S = super().from_string(value, axis).as_tuple() - - if axis == SpatialAxis.X and not 0 <= D < 360: - raise ValueError(f"DMS coordinate string {value} is outside the permitted longitude range [0:00:00, 360:00:00).") - - if axis == SpatialAxis.Y: - if D < -90 or D > 90 or ((D in (-90, 90)) and (M or S)): - raise ValueError(f"DMS coordinate string {value} is outside the permitted latitude range [-90:00:00, 90:00:00].") - - return cls(D, M, S) diff --git a/carta/validation.py b/carta/validation.py index a41cba6..52b3790 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -4,7 +4,8 @@ import functools import inspect -from .util import CartaValidationFailed, PixelValue, AngularSize, WorldCoordinate +from .util import CartaValidationFailed +from .units import PixelValue, AngularSize, WorldCoordinate class Parameter: diff --git a/tests/test_util.py b/tests/test_units.py similarity index 98% rename from tests/test_util.py rename to tests/test_units.py index bb19125..34b62f7 100644 --- a/tests/test_util.py +++ b/tests/test_units.py @@ -1,7 +1,7 @@ import types import pytest -from carta.util import PixelValue, AngularSize, DegreesSize, ArcminSize, ArcsecSize, MilliarcsecSize, MicroarcsecSize, WorldCoordinate, DegreesCoordinate, HMSCoordinate, DMSCoordinate +from carta.units import PixelValue, AngularSize, DegreesSize, ArcminSize, ArcsecSize, MilliarcsecSize, MicroarcsecSize, WorldCoordinate, DegreesCoordinate, HMSCoordinate, DMSCoordinate from carta.constants import NumberFormat as NF, SpatialAxis as SA From f8a97fb26019e54319e6e659d4b114a3fd8bd00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 20:44:33 +0200 Subject: [PATCH 3/3] Fixed module list in docs --- docs/source/carta.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index e5805a2..bda3ba5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -34,7 +34,7 @@ carta.image module :show-inheritance: carta.metadata module ------------------- +--------------------- .. automodule:: carta.metadata :members: @@ -65,6 +65,14 @@ carta.token module :undoc-members: :show-inheritance: +carta.units module +------------------ + +.. automodule:: carta.units + :members: + :undoc-members: + :show-inheritance: + carta.util module -----------------