diff --git a/CHANGES.rst b/CHANGES.rst index b9cad21e7..cae8fbe7c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -45,6 +45,10 @@ Enhancements and Fixes - Tables returned by RegistryResource.get_tables() now have a utype attribute [#576] + +- MIVOT module: If the MIVOT annotation block contains a valid instance of the ``mango:EpochPosition`` class, + the dynamic object describing the mapped data can generate a valid SkyCoord instance. [#591] + - Registry Spatial constraint now supports Astropy Quantities for the radius argument [#594] diff --git a/docs/mivot/_images/mangoEpochPosition.png b/docs/mivot/_images/mangoEpochPosition.png index 3d158c736..04a4e748f 100644 Binary files a/docs/mivot/_images/mangoEpochPosition.png and b/docs/mivot/_images/mangoEpochPosition.png differ diff --git a/docs/mivot/index.rst b/docs/mivot/index.rst index 33eed9ea1..c86b6df8a 100644 --- a/docs/mivot/index.rst +++ b/docs/mivot/index.rst @@ -164,6 +164,27 @@ with the `astropy.io.votable` API: In this case, it is up to the user to ensure that the read data rows are those mapped by the Mivot annotations. +Get a SkyCoord Instance Directly From the Annotations +----------------------------------------------------- + +Once you get a ``MivotInstance`` representing the last row read, you can use it to create an ``astropy.SkyCoord`` object. + +.. code-block:: python + :caption: Accessing the model view of Astropy table rows + + from pyvo.mivot import MivotViewer + + m_viewer = MivotViewer(path_to_votable) + mivot_instance = m_viewer.dm_instance + print(mivot_instance.get_SkyCoord()) + + +This feature works under the condition that the annotations contain a valid instance of ``mango:EPochPosition``, otherwise +a ``NoMatchingDMTypeError`` is thrown. +Although not a standard at the time of writing, the class structure supported by this implementation must match the figure above. + + For XML Hackers --------------- diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py new file mode 100644 index 000000000..7bdf48774 --- /dev/null +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -0,0 +1,187 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Utility transforming MIVOT annotation into SkyCoord instances +""" + +from astropy.coordinates import SkyCoord +from astropy import units as u +from astropy.coordinates import ICRS, Galactic, FK4, FK5 +from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError + + +class MangoRoles: + """ + Place holder for the roles (attribute names) of the mango:EpochPosition class + """ + LONGITUDE = "longitude" + LATITUDE = "latitude" + PM_LONGITUDE = "pmLongitude" + PM_LATITUDE = "pmLatitude" + PARALLAX = "parallax" + RADIAL_VELOCITY = "radialVelocity" + EPOCH = "epoch" + FRAME = "frame" + EQUINOX = "equinox" + PMCOSDELTAPPLIED = "pmCosDeltApplied" + + +# Mapping of the MANGO parameters on the SkyCoord parameters +skycoord_param_default = { + MangoRoles.LONGITUDE: 'ra', MangoRoles.LATITUDE: 'dec', MangoRoles.PARALLAX: 'distance', + MangoRoles.PM_LONGITUDE: 'pm_ra_cosdec', MangoRoles.PM_LATITUDE: 'pm_dec', + MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} + +skycoord_param_galactic = { + MangoRoles.LONGITUDE: 'l', MangoRoles.LATITUDE: 'b', MangoRoles.PARALLAX: 'distance', + MangoRoles.PM_LONGITUDE: 'pm_l_cosb', MangoRoles.PM_LATITUDE: 'pm_b', + MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} + + +class SkyCoordBuilder(object): + ''' + Utility generating SkyCoord instances from MIVOT annotations + + - SkyCoord instances can only be built from model classes containing the minimal + set of required parameters (a position). + - In this implementation, only the mango:EpochPosition class is supported since + it contains the information required to compute the epoch propagation which is a major use-case + ''' + + def __init__(self, mivot_instance_dict): + ''' + Constructor + + parameters + ----------- + mivot_instance_dict: viewer.MivotInstance.to_dict() + Internal dictionary of the dynamic Python object generated from the MIVOT block + ''' + self._mivot_instance_dict = mivot_instance_dict + self._map_coord_names = None + + def build_sky_coord(self): + """ + Build a SkyCoord instance from the MivotInstance dictionary. + The operation requires the dictionary to have ``mango:EpochPosition`` as dmtype + This is a public method which could be extended to support other dmtypes. + + returns + ------- + SkyCoord + Instance built by the method + + raises + ------ + NoMatchingDMTypeError + if the SkyCoord instance cannot be built. + """ + if self._mivot_instance_dict and self._mivot_instance_dict["dmtype"] == "mango:EpochPosition": + return self._build_sky_coord_from_mango() + raise NoMatchingDMTypeError( + "No INSTANCE with dmtype='mango:EpochPosition' has been found:" + " cannot build a SkyCoord from annotations") + + def _set_year_time_format(self, hk_field, besselian=False): + """ + Format a date expressed in year as [scale]year + + parameters + ---------- + hk_field: dict + MIVOT instance attribute + besselian: boolean + besselian time scale is used if True, otherwise Julian (default) + + returns + ------- + string + attribute value formatted as [scale]year + """ + scale = "J" if not besselian else "B" + return (f"{scale}{hk_field['value']}" if hk_field["unit"] in ("yr", "year") + else hk_field["value"]) + + def _get_space_frame(self, obstime=None): + """ + Build an astropy space frame instance from the MIVOT annotations. + + - Equinox are supported for FK4/5 + - Reference location is not supported + + parameters + ---------- + obstime: str + Observation time is given to the space frame builder (this method) because + it must be set by the coordinate system constructor in case of FK4 frame. + returns + ------- + FK2, FK5, ICRS or Galactic + Astropy space frame instance + """ + coo_sys = self._mivot_instance_dict["coordSys"] + equinox = None + frame = coo_sys["spaceRefFrame"]["value"].lower() + + if frame == 'fk4': + self._map_coord_names = skycoord_param_default + if "equinox" in coo_sys: + equinox = self._set_year_time_format(coo_sys["equinox"], True) + return FK4(equinox=equinox, obstime=obstime) + return FK4() + + if frame == 'fk5': + self._map_coord_names = skycoord_param_default + if "equinox" in coo_sys: + equinox = self._set_year_time_format(coo_sys["equinox"]) + return FK5(equinox=equinox) + return FK5() + + if frame == 'galactic': + self._map_coord_names = skycoord_param_galactic + return Galactic() + + self._map_coord_names = skycoord_param_default + return ICRS() + + def _build_sky_coord_from_mango(self): + """ + Build silently a SkyCoord instance from the ``mango:EpochPosition instance``. + No error is trapped, unconsistencies in the ``mango:EpochPosition`` instance will + raise Astropy errors. + + - The epoch (obstime) is meant to be given in year. + - ICRS frame is taken by default + - The cos-delta correction is meant to be applied. + The case ``mango:pmCosDeltApplied = False`` is not supported yet + + returns + ------- + SkyCoord + instance built by the method + """ + kwargs = {} + kwargs["frame"] = self._get_space_frame() + + for key, value in self._map_coord_names.items(): + # ignore not set parameters + if key not in self._mivot_instance_dict: + continue + hk_field = self._mivot_instance_dict[key] + # format the observation time (J-year by default) + if value == "obstime": + # obstime must be set into the KK4 frame but not as an input parameter + fobstime = self._set_year_time_format(hk_field) + if isinstance(kwargs["frame"], FK4): + kwargs["frame"] = self._get_space_frame(obstime=fobstime) + else: + kwargs[value] = fobstime + # Convert the parallax (mango) into a distance + elif value == "distance": + kwargs[value] = (hk_field["value"] + * u.Unit(hk_field["unit"]).to(u.parsec, equivalencies=u.parallax())) + kwargs[value] = kwargs[value] * u.parsec + elif "unit" in hk_field and hk_field["unit"]: + kwargs[value] = hk_field["value"] * u.Unit(hk_field["unit"]) + else: + kwargs[value] = hk_field["value"] + return SkyCoord(**kwargs) diff --git a/pyvo/mivot/features/static_reference_resolver.py b/pyvo/mivot/features/static_reference_resolver.py index 8e194c5c8..fd376e924 100644 --- a/pyvo/mivot/features/static_reference_resolver.py +++ b/pyvo/mivot/features/static_reference_resolver.py @@ -2,7 +2,7 @@ Class used to resolve each static REFERENCE found in mivot_block. """ from copy import deepcopy -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError from pyvo.mivot.utils.xpath_utils import XPath from pyvo.utils.prototype import prototype_feature @@ -33,7 +33,7 @@ def resolve(annotation_seeker, templates_ref, mivot_block): The number of references resolved. Raises ------ - MappingException + MappingError If the reference cannot be resolved. NotImplementedError If the reference is dynamic. @@ -50,7 +50,7 @@ def resolve(annotation_seeker, templates_ref, mivot_block): target = annotation_seeker.get_templates_instance_by_dmid(templates_ref, dmref) found_in_global = False if target is None: - raise MivotException(f"Cannot resolve reference={dmref}") + raise MivotError(f"Cannot resolve reference={dmref}") # Resolve static references recursively if not found_in_global: StaticReferenceResolver.resolve(annotation_seeker, templates_ref, ele) diff --git a/pyvo/mivot/seekers/annotation_seeker.py b/pyvo/mivot/seekers/annotation_seeker.py index 4f74eca5c..5c0d718a6 100644 --- a/pyvo/mivot/seekers/annotation_seeker.py +++ b/pyvo/mivot/seekers/annotation_seeker.py @@ -3,7 +3,7 @@ Utilities for extracting sub-blocks from a MIVOT mapping block. """ import logging -from pyvo.mivot.utils.exceptions import MivotException, MappingException +from pyvo.mivot.utils.exceptions import MivotError, MappingError from pyvo.mivot.utils.vocabulary import Att, Ele from pyvo.mivot.utils.vocabulary import Constant from pyvo.mivot.utils.xpath_utils import XPath @@ -73,7 +73,7 @@ def _find_templates_blocks(self): logging.debug("Found " + Ele.TEMPLATES + " without " + Att.tableref) self._templates_blocks["DEFAULT"] = child else: - raise MivotException(Ele.TEMPLATES + " without " + Att.tableref + " must be unique") + raise MivotError(Ele.TEMPLATES + " without " + Att.tableref + " must be unique") def _rename_ref_and_join(self): """ @@ -391,7 +391,7 @@ def get_collection_item_by_primarykey(self, coll_dmid, key_value): Raises ------ MivotElementNotFound: If no element matches the criteria. - MappingException: If more than one element matches the criteria. + MappingError: If more than one element matches the criteria. """ eset = XPath.x_path(self._globals_block, ".//" + Ele.COLLECTION + "[@" + Att.dmid + "='" + coll_dmid + "']/" + Ele.INSTANCE + "/" + Att.primarykey @@ -400,14 +400,14 @@ def get_collection_item_by_primarykey(self, coll_dmid, key_value): message = (f"{Ele.INSTANCE} with {Att.primarykey} = {key_value} in " f"{Ele.COLLECTION} {Att.dmid} {key_value} not found" ) - raise MivotException(message) + raise MivotError(message) if len(eset) > 1: message = ( f"More than one {Ele.INSTANCE} with {Att.primarykey}" f" = {key_value} found in {Ele.COLLECTION} " f"{Att.dmid} {key_value}" ) - raise MappingException(message) + raise MappingError(message) logging.debug(Ele.INSTANCE + " with " + Att.primarykey + "=%s found in " + Ele.COLLECTION + " " + Att.dmid + "=%s", key_value, coll_dmid) diff --git a/pyvo/mivot/tests/test_mivot_instance.py b/pyvo/mivot/tests/test_mivot_instance.py index d79fae900..1e314bf44 100644 --- a/pyvo/mivot/tests/test_mivot_instance.py +++ b/pyvo/mivot/tests/test_mivot_instance.py @@ -19,14 +19,12 @@ "dmtype": "RealQuantity", "value": 52.2340018, "unit": "deg", - "astropy_unit": {}, "ref": "RAICRS" }, "latitude": { "dmtype": "RealQuantity", "value": 59.8937333, "unit": "deg", - "astropy_unit": {}, "ref": "DEICRS" } } diff --git a/pyvo/mivot/tests/test_mivot_viewer.py b/pyvo/mivot/tests/test_mivot_viewer.py index 866839c8e..20fb63f85 100644 --- a/pyvo/mivot/tests/test_mivot_viewer.py +++ b/pyvo/mivot/tests/test_mivot_viewer.py @@ -8,7 +8,7 @@ from astropy.utils.data import get_pkg_data_filename from pyvo.mivot.utils.vocabulary import Constant from pyvo.mivot.utils.dict_utils import DictUtils -from pyvo.mivot.utils.exceptions import MappingException +from pyvo.mivot.utils.exceptions import MappingError from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot import MivotViewer from astropy import version as astropy_version @@ -84,9 +84,9 @@ def test_no_mivot(path_no_mivot): assert m_viewer.get_globals_models() is None assert m_viewer.get_templates_models() is None - with pytest.raises(MappingException): + with pytest.raises(MappingError): m_viewer._connect_table('_PKTable') - with pytest.raises(MappingException): + with pytest.raises(MappingError): m_viewer._connect_table() assert m_viewer.next_table_row() is None diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py new file mode 100644 index 000000000..346bc6769 --- /dev/null +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -0,0 +1,234 @@ +''' +The first service in operation that annotates query responses in the fly is Vizier +https://cds/viz-bin/mivotconesearch/VizierParams +Data are mapped on the mango:EpochPropagtion class as it is implemented in the current code. +This test case is based on 2 VOTables: +Both tests check the generation of SkyCoord instances from the MivotInstances built +for the output of this service. +''' +import pytest +from pyvo.mivot.version_checker import check_astropy_version +from pyvo.mivot.viewer.mivot_instance import MivotInstance +from pyvo.mivot.features.sky_coord_builder import SkyCoordBuilder +from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError + +# annotations generated by Vizier as given to the MivotInstance +vizier_dict = { + "dmtype": "mango:EpochPosition", + "longitude": { + "dmtype": "ivoa:RealQuantity", + "value": 52.26722684, + "unit": "deg", + "ref": "RAICRS", + }, + "latitude": { + "dmtype": "ivoa:RealQuantity", + "value": 59.94033461, + "unit": "deg", + "ref": "DEICRS", + }, + "pmLongitude": { + "dmtype": "ivoa:RealQuantity", + "value": -0.82, + "unit": "mas/yr", + "ref": "pmRA", + }, + "pmLatitude": { + "dmtype": "ivoa:RealQuantity", + "value": -1.85, + "unit": "mas/yr", + "ref": "pmDE", + }, + "epoch": { + "dmtype": "ivoa:RealQuantity", + "value": 1991.25, + "unit": "yr", + "ref": None, + }, + "coordSys": { + "dmtype": "coords:SpaceSys", + "dmid": "SpaceFrame_ICRS", + "dmrole": "coords:Coordinate.coordSys", + "spaceRefFrame": { + "dmtype": "coords:SpaceFrame", + "value": "ICRS", + "unit": None, + "ref": None, + }, + }, +} +# The same edited by hand (parallax added and FK5 + Equinox frame) +vizier_equin_dict = { + "dmtype": "mango:EpochPosition", + "longitude": { + "dmtype": "ivoa:RealQuantity", + "value": 52.26722684, + "unit": "deg", + "ref": "RAICRS", + }, + "latitude": { + "dmtype": "ivoa:RealQuantity", + "value": 59.94033461, + "unit": "deg", + "ref": "DEICRS", + }, + "pmLongitude": { + "dmtype": "ivoa:RealQuantity", + "value": -0.82, + "unit": "mas/yr", + "ref": "pmRA", + }, + "pmLatitude": { + "dmtype": "ivoa:RealQuantity", + "value": -1.85, + "unit": "mas/yr", + "ref": "pmDE", + }, + "parallax": { + "dmtype": "ivoa:RealQuantity", + "value": 0.6, + "unit": "mas", + "ref": "parallax", + }, + "epoch": { + "dmtype": "ivoa:RealQuantity", + "value": 1991.25, + "unit": "yr", + "ref": None, + }, + "coordSys": { + "dmtype": "coords:SpaceSys", + "dmid": "SpaceFrame_ICRS", + "dmrole": "coords:Coordinate.coordSys", + "spaceRefFrame": { + "dmtype": "coords:SpaceFrame.spaceRefFrame", + "value": "FK5", + "unit": None, + "ref": None, + }, + "equinox": { + "dmtype": "coords:SpaceFrame.equinox", + "value": "2012", + "unit": "yr", + }, + }, +} + +# The same edited mapped on a dummy class +vizier_dummy_type = { + "dmtype": "mango:DumyType", + "longitude": { + "dmtype": "ivoa:RealQuantity", + "value": 52.26722684, + "unit": "deg", + "ref": "RAICRS", + }, + "latitude": { + "dmtype": "ivoa:RealQuantity", + "value": 59.94033461, + "unit": "deg", + "ref": "DEICRS", + }, + "pmLongitude": { + "dmtype": "ivoa:RealQuantity", + "value": -0.82, + "unit": "mas/yr", + "ref": "pmRA", + }, + "pmLatitude": { + "dmtype": "ivoa:RealQuantity", + "value": -1.85, + "unit": "mas/yr", + "ref": "pmDE", + }, + "parallax": { + "dmtype": "ivoa:RealQuantity", + "value": 0.6, + "unit": "mas", + "ref": "parallax", + }, + "epoch": { + "dmtype": "ivoa:RealQuantity", + "value": 1991.25, + "unit": "yr", + "ref": None, + }, + "coordSys": { + "dmtype": "coords:SpaceSys", + "dmid": "SpaceFrame_ICRS", + "dmrole": "coords:Coordinate.coordSys", + "spaceRefFrame": { + "dmtype": "coords:SpaceFrame.spaceRefFrame", + "value": "FK5", + "unit": None, + "ref": None, + }, + "equinox": { + "dmtype": "coords:SpaceFrame.equinox", + "value": "2012", + "unit": "yr", + }, + }, +} + + +def test_no_matching_mapping(): + """ + Test that a NoMatchingDMTypeError is raised not mapped on mango:EpochPosition + """ + with pytest.raises(NoMatchingDMTypeError): + mivot_instance = MivotInstance(**vizier_dummy_type) + scb = SkyCoordBuilder(mivot_instance.to_dict()) + scb.build_sky_coord() + + +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") +def test_vizier_output(): + """ Test the SkyCoord issued from the Vizier response + """ + mivot_instance = MivotInstance(**vizier_dict) + scb = SkyCoordBuilder(mivot_instance.to_dict()) + scoo = scb.build_sky_coord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") + scoo = mivot_instance.get_SkyCoord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") + + vizier_dict["coordSys"]["spaceRefFrame"]["value"] = "Galactic" + mivot_instance = MivotInstance(**vizier_dict) + scoo = mivot_instance.get_SkyCoord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") + + vizier_dict["coordSys"]["spaceRefFrame"]["value"] = "QWERTY" + mivot_instance = MivotInstance(**vizier_dict) + scoo = mivot_instance.get_SkyCoord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") + + +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") +def test_vizier_output_with_equinox_and_parallax(): + """Test the SkyCoord issued from the modofier Vizier response * + (parallax added and FK5 + Equinox frame) + """ + mivot_instance = MivotInstance(**vizier_equin_dict) + scb = SkyCoordBuilder(mivot_instance.to_dict()) + scoo = scb.build_sky_coord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") + + vizier_equin_dict["coordSys"]["spaceRefFrame"]["value"] = "FK4" + mivot_instance = MivotInstance(**vizier_equin_dict) + scoo = mivot_instance.get_SkyCoord() + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") diff --git a/pyvo/mivot/tests/test_user_api.py b/pyvo/mivot/tests/test_user_api.py index 8ed6e9141..04277c0e6 100644 --- a/pyvo/mivot/tests/test_user_api.py +++ b/pyvo/mivot/tests/test_user_api.py @@ -234,28 +234,24 @@ def test_with_dict(path_to_votable): "dmtype": "ivoa:RealQuantity", "value": 359.94372764, "unit": "deg", - "astropy_unit": {}, "ref": "RAICRS", }, "latitude": { "dmtype": "ivoa:RealQuantity", "value": -0.28005255, "unit": "deg", - "astropy_unit": {}, "ref": "DEICRS", }, "pmLongitude": { "dmtype": "ivoa:RealQuantity", "value": -5.14, "unit": "mas/yr", - "astropy_unit": {}, "ref": "pmRA", }, "pmLatitude": { "dmtype": "ivoa:RealQuantity", "value": -25.43, "unit": "mas/yr", - "astropy_unit": {}, "ref": "pmDE", }, "epoch": { diff --git a/pyvo/mivot/tests/test_vizier_cs.py b/pyvo/mivot/tests/test_vizier_cs.py index 52c5ed696..573430cec 100644 --- a/pyvo/mivot/tests/test_vizier_cs.py +++ b/pyvo/mivot/tests/test_vizier_cs.py @@ -21,7 +21,7 @@ from urllib.request import urlretrieve from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot import MivotViewer -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError @pytest.fixture @@ -125,5 +125,5 @@ def test_bad_ref(path_to_badref, delt_coo): """ Test that the epoch propagation works with all FIELDs referenced by name or by ID """ # Test with all FILELDs referenced by names - with (pytest.raises(MivotException, match="Attribute mango:EpochPosition.epoch can not be set.*")): + with (pytest.raises(MivotError, match="Attribute mango:EpochPosition.epoch can not be set.*")): MivotViewer(votable_path=path_to_badref) diff --git a/pyvo/mivot/tests/test_xml_viewer.py b/pyvo/mivot/tests/test_xml_viewer.py index 8431c500b..e0dde04ea 100644 --- a/pyvo/mivot/tests/test_xml_viewer.py +++ b/pyvo/mivot/tests/test_xml_viewer.py @@ -10,7 +10,7 @@ from astropy.utils.data import get_pkg_data_filename from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot import MivotViewer -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") @@ -18,27 +18,27 @@ def test_xml_viewer(m_viewer): m_viewer.next() xml_viewer = m_viewer.xml_viewer - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmrole wrong_role in any instances of the VOTable"): xml_viewer.get_instance_by_role("wrong_role") - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmrole wrong_role in any instances of the VOTable"): xml_viewer.get_instance_by_role("wrong_role", all_instances=True) - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmtype wrong_dmtype in any instances of the VOTable"): xml_viewer.get_instance_by_type("wrong_dmtype") - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmtype wrong_dmtype in any instances of the VOTable"): xml_viewer.get_instance_by_type("wrong_dmtype", all_instances=True) - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmrole wrong_role in any collections of the VOTable"): xml_viewer.get_collection_by_role("wrong_role") - with pytest.raises(MivotException, + with pytest.raises(MivotError, match="Cannot find dmrole wrong_role in any collections of the VOTable"): xml_viewer.get_collection_by_role("wrong_role", all_instances=True) diff --git a/pyvo/mivot/utils/dict_utils.py b/pyvo/mivot/utils/dict_utils.py index bd9d76e97..679c55a23 100644 --- a/pyvo/mivot/utils/dict_utils.py +++ b/pyvo/mivot/utils/dict_utils.py @@ -3,7 +3,7 @@ """ import json import logging -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError from pyvo.mivot.utils.json_encoder import MivotJsonEncoder @@ -31,7 +31,7 @@ def read_dict_from_file(filename, fatal=False): return json.load(file, object_pairs_hook=OrderedDict) except Exception as exception: if fatal: - raise MivotException("reading {}".format(filename)) + raise MivotError("reading {}".format(filename)) else: logging.error("{} reading {}".format(exception, filename)) diff --git a/pyvo/mivot/utils/exceptions.py b/pyvo/mivot/utils/exceptions.py index 624e88680..16944d090 100644 --- a/pyvo/mivot/utils/exceptions.py +++ b/pyvo/mivot/utils/exceptions.py @@ -3,19 +3,19 @@ 3 exception classes - AstropyVersionException that prevent to use the package -- MappingException if the annotation cannot be processed (e.g. no MIVOT block) +- MappingError if the annotation cannot be processed (e.g. no MIVOT block) but the VOtable parsing can continue -- MivotException in any other case (block the processing) +- MivotError in any other case (block the processing) """ -class MivotException(Exception): +class MivotError(Exception): """ The annotation block is there but something went wrong with its processing """ -class MappingException(Exception): +class MappingError(Exception): """ Exception raised if a Resource or MIVOT element can't be mapped for one of these reasons: - It doesn't match with any Resource/MIVOT element expected. @@ -25,6 +25,16 @@ class MappingException(Exception): """ +class NoMatchingDMTypeError(TypeError): + """ + Exception thrown when some PyVO code misses MIVOT element: + - When trying to build a SkyCoord while there is no position in the annotations + - is mapped to a model unknown to the PyVO code. + This exception is never caught by the mivot package. + It must be handled by the calling code. + """ + + class AstropyVersionException(Exception): """ Exception raised if the version of astropy is not compatible with MIVOT. diff --git a/pyvo/mivot/utils/xml_utils.py b/pyvo/mivot/utils/xml_utils.py index 7c3f40cc9..83f15a108 100644 --- a/pyvo/mivot/utils/xml_utils.py +++ b/pyvo/mivot/utils/xml_utils.py @@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET from pyvo.mivot.utils.vocabulary import Constant from pyvo.mivot.utils.vocabulary import Att -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError class XmlUtils: @@ -95,7 +95,7 @@ def add_column_indices(mapping_block, index_map): break if not field_desc: if not ele.get(Att.value): - raise MivotException( + raise MivotError( f"Attribute {ele.get(Att.dmrole)} can not be set:" f" references a non existing column: {attr_ref} " f"and has no default value") diff --git a/pyvo/mivot/viewer/mivot_instance.py b/pyvo/mivot/viewer/mivot_instance.py index 2b6a14c32..47cfacdea 100644 --- a/pyvo/mivot/viewer/mivot_instance.py +++ b/pyvo/mivot/viewer/mivot_instance.py @@ -10,14 +10,15 @@ Although attribute values can be changed by users, this class is first meant to provide a convenient access the mapped VOTable data """ -from astropy import time -from pyvo.mivot.utils.vocabulary import unit_mapping, Constant +from pyvo.mivot.utils.vocabulary import Constant from pyvo.utils.prototype import prototype_feature from pyvo.mivot.utils.mivot_utils import MivotUtils from pyvo.mivot.utils.dict_utils import DictUtils +from pyvo.mivot.features.sky_coord_builder import SkyCoordBuilder + # list of model leaf parameters that must be hidden for the final user -hk_parameters = ["astropy_unit", "ref"] +hk_parameters = ["ref"] @prototype_feature('MIVOT') @@ -53,7 +54,7 @@ def to_hk_dict(self): housekeeping data such as column references. This might be used to apply the mapping out of the MivotViewer context """ - return self._get_class_dict(self) + return self._get_class_dict(self, slim=False) def to_dict(self): """ @@ -98,10 +99,6 @@ def _create_class(self, **kwargs): if key == 'unit': # We convert the unit to astropy unit or to astropy time format if possible # The first Vizier implementation used mas/year for the mapped pm unit: let's correct it value = value.replace("year", "yr") if value else None - if value in unit_mapping.keys(): - setattr(self, "astropy_unit", unit_mapping[value]) - elif value in time.TIME_FORMATS.keys(): - setattr(self, "astropy_unit_time", value) def update(self, row, ref=None): """ @@ -128,6 +125,14 @@ def update(self, row, ref=None): setattr(self, self._remove_model_name(key), MivotUtils.cast_type_value(row[ref], getattr(self, 'dmtype'))) + def get_SkyCoord(self): + """ + returns + ------- + - a SkyCoord instance or None + """ + return SkyCoordBuilder(self.to_dict()).build_sky_coord() + @staticmethod def _remove_model_name(value): """ @@ -178,6 +183,7 @@ def _get_class_dict(self, obj, classkey=None, slim=False): dict or object The serializable dictionary representation of the input. """ + if isinstance(obj, dict): data = {} for (k, v) in obj.items(): @@ -188,7 +194,7 @@ def _get_class_dict(self, obj, classkey=None, slim=False): elif hasattr(obj, "__iter__") and not isinstance(obj, str): return [self._get_class_dict(v, classkey, slim=slim) for v in obj] elif hasattr(obj, "__dict__"): - data = dict([(key, self._get_class_dict(value, classkey, slim=slim)) + data = dict([(key, obj._get_class_dict(value, classkey, slim=slim)) for key, value in obj.__dict__.items() if not callable(value) and not key.startswith('_')]) # remove the house keeping parameters diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index a6f1224f7..2521036d2 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -29,8 +29,8 @@ from pyvo.dal import DALResults from pyvo.mivot.utils.vocabulary import Ele, Att from pyvo.mivot.utils.vocabulary import Constant, NoMapping -from pyvo.mivot.utils.exceptions import (MappingException, - MivotException, +from pyvo.mivot.utils.exceptions import (MappingError, + MivotError, AstropyVersionException) from pyvo.mivot.utils.xml_utils import XmlUtils from pyvo.mivot.utils.xpath_utils import XPath @@ -100,7 +100,7 @@ def __init__(self, votable_path, tableref=None): self._set_mapped_tables() self._connect_table(tableref) self._init_instance() - except MappingException as mnf: + except MappingError as mnf: logging.error(str(mnf)) def __enter__(self): @@ -325,7 +325,7 @@ def get_first_instance_dmtype(self, tableref=None): elif child[0] in collection: return collection[0].get(Att.dmtype) else: - raise MivotException("Can't find the first " + Ele.INSTANCE + raise MivotError("Can't find the first " + Ele.INSTANCE + "/" + Ele.COLLECTION + " in " + Ele.TEMPLATES) def _connect_table(self, tableref=None): @@ -340,7 +340,7 @@ def _connect_table(self, tableref=None): Identifier of the table. If None, connects to the first table. """ if not self._resource_seeker: - raise MappingException("No mapping block found") + raise MappingError("No mapping block found") stableref = tableref if tableref is None: @@ -350,7 +350,7 @@ def _connect_table(self, tableref=None): "the mapping will be applied to the first table." ) elif tableref not in self._mapped_tables: - raise MappingException(f"The table {self._connected_tableref} doesn't match with any " + raise MappingError(f"The table {self._connected_tableref} doesn't match with any " f"mapped_table ({self._mapped_tables}) encountered in " + Ele.TEMPLATES ) @@ -359,11 +359,11 @@ def _connect_table(self, tableref=None): self._connected_table = self._resource_seeker.get_table(tableref) if self.connected_table is None: - raise MivotException(f"Cannot find table {stableref} in VOTable") + raise MivotError(f"Cannot find table {stableref} in VOTable") logging.debug("table %s found in VOTable", stableref) self._templates = deepcopy(self.annotation_seeker.get_templates_block(tableref)) if self._templates is None: - raise MivotException("Cannot find " + Ele.TEMPLATES + f" {stableref} ") + raise MivotError("Cannot find " + Ele.TEMPLATES + f" {stableref} ") logging.debug(Ele.TEMPLATES + " %s found ", stableref) self._table_iterator = TableIterator(self._connected_tableref, self.connected_table.to_table()) @@ -432,7 +432,7 @@ def _set_resource(self): """ if len(self._parsed_votable.resources) < 1: - raise MivotException("No resource detected in the VOTable") + raise MivotError("No resource detected in the VOTable") rnb = 0 for res in self._parsed_votable.resources: if res.type.lower() == "results": @@ -440,14 +440,14 @@ def _set_resource(self): self._resource = self._parsed_votable.resources[rnb] return rnb += 1 - raise MivotException("No resource @type='results'detected in the VOTable") + raise MivotError("No resource @type='results'detected in the VOTable") def _set_mapping_block(self): """ Set the mapping block found in the resource and set the annotation_seeker """ if NoMapping.search(self._resource.mivot_block.content): - raise MappingException("Mivot block is not found") + raise MappingError("Mivot block is not found") # The namespace should be removed self._mapping_block = ( etree.fromstring(self._resource.mivot_block.content diff --git a/pyvo/mivot/viewer/xml_viewer.py b/pyvo/mivot/viewer/xml_viewer.py index 75103a0ec..fd69e79ce 100644 --- a/pyvo/mivot/viewer/xml_viewer.py +++ b/pyvo/mivot/viewer/xml_viewer.py @@ -3,7 +3,7 @@ XMLViewer provides several getters on XML instances built by `pyvo.mivot.viewer.mivot_viewer`. """ -from pyvo.mivot.utils.exceptions import MivotException +from pyvo.mivot.utils.exceptions import MivotError from pyvo.mivot.utils.xpath_utils import XPath from pyvo.utils.prototype import prototype_feature @@ -60,7 +60,7 @@ def get_instance_by_role(self, dmrole, all_instances=False): dmrole) if len(instances) == 0: - raise MivotException( + raise MivotError( f"Cannot find dmrole {dmrole} in any instances of the VOTable") if all_instances is False: @@ -98,7 +98,7 @@ def get_instance_by_type(self, dmtype, all_instances=False): dmtype) if len(instances) == 0: - raise MivotException( + raise MivotError( f"Cannot find dmtype {dmtype} in any instances of the VOTable") if all_instances is False: @@ -136,7 +136,7 @@ def get_collection_by_role(self, dmrole, all_instances=False): dmrole) if len(collections) == 0: - raise MivotException( + raise MivotError( f"Cannot find dmrole {dmrole} in any collections of the VOTable") if all_instances is False: