Source code for pyvo.mivot.writer.header_mapper
"""
``HeaderMapper`` class source
"""
import logging
from pyvo.mivot.glossary import Roles, EpochPositionAutoMapping
from pyvo.mivot.utils.dict_utils import DictUtils
[docs]
class HeaderMapper:
    """
    This utility class generates dictionaries from header elements of a VOTable.
    These dictionaries are used as input parameters by `pyvo.mivot.writer.InstancesFromModels`
    to create MIVOT instances that are placed in the GLOBALS block or in the TEMPLATES.
    In the current implementation, the following elements can be extracted:
    - COOSYS -> coords:SpaceSys
    - TIMESYS - coords:TimeSys
    - INFO -> mango:QueryOrigin
    - FIELD -> mango:EpochPosition
    """
    def __init__(self, votable):
        """
        Constructor parameters:
        Parameters
        ----------
        votable : astropy.io.votable.tree.VOTableFile
            parsed votable from which INFO element are processed
        """
        self._votable = votable
    def _check_votable_head_element(self, parameter):
        """
        Check that the parameter is a valid value.
        .. note::
            Vizier uses the ``UNKNOWN`` word to tag not set values
        """
        return parameter is not None and parameter != "UNKNOWN"
    def _extract_query_origin(self):
        """
        Create a mapping dictionary from ``INFO`` elements found
        in the VOTable header.
        This dictionary is used to populate the ``mango:QueryOrigin`` attributes
        Returns
        -------
        dict
            Dictionary that is part of the input parameter for
            :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_query_origin`
        """
        mapping = {}
        for info in self._votable.infos:
            if info.name in Roles.QueryOrigin:
                mapping[info.name] = info.value
        return mapping
    def _extract_data_origin(self, resource):
        """
        Create a mapping dictionary from  ``INFO`` elements found
        in the header of the first VOTable resource.
        This dictionary is used to populate the ``mango:QueryOrigin`` part of
        the ``mango:QueryOrigin`` instance.
        Returns
        -------
        dict
            Dictionary that is part of the input parameter for
            :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_query_origin`
        """
        mapping = {}
        article = None
        for info in resource.infos:
            if info.name in Roles.DataOrigin or info.name == "creator":
                if DictUtils.add_array_element(mapping, "dataOrigin"):
                    article = {}
                if info.name != "creator":
                    article[info.name] = info.value
                else:
                    DictUtils.add_array_element(article, "creators")
                    article["creators"].append(info.value)
        for info in resource.infos:
            art_ref = {}
            if info.name in Roles.Article:
                DictUtils.add_array_element(article, "articles")
                art_ref[info.name] = info.value
            if art_ref:
                article["articles"].append(art_ref)
        mapping["dataOrigin"].append(article)
        return mapping
[docs]
    def extract_origin_mapping(self):
        """
        Create a mapping dictionary from all  VOTable ``INFO`` elements.
        This dictionary is used to build a ``mango:QueryOrigin`` INSTANCE
        - INFO elements located in the VOTable header are used to build the ``mango:QueryOrigin``
          part which scope is the whole VOtable by construction (one query -> one VOTable)
        - INFO elements located in the resource header are used to build the ``mango:DataOrigin``
          part which scope is the data located in this resource.
        Returns
        -------
        dict
            Dictionary that can be used as input parameter for
            :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_query_origin`
        """
        mapping = self._extract_query_origin()
        for resource in self._votable.resources:
            mapping = {**mapping, **self._extract_data_origin(resource)}
        return mapping
[docs]
    def extract_coosys_mapping(self):
        """
        Create a mapping dictionary for each ``COOSYS`` element found
        in the first VOTable resource.
        Returns
        -------
        [dict]
            Array of dictionaries which items can be used as input parameter for
            :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_simple_space_frame`
        """
        mappings = []
        for resource in self._votable.resources:
            for coordinate_system in resource.coordinate_systems:
                mapping = {}
                if not self._check_votable_head_element(coordinate_system.system):
                    logging.warning(f"Not valid COOSYS found: ignored in MIVOT: {coordinate_system}")
                    continue
                mapping["spaceRefFrame"] = coordinate_system.system
                if self._check_votable_head_element(coordinate_system.equinox):
                    mapping["equinox"] = coordinate_system.equinox
                if self._check_votable_head_element(coordinate_system.epoch):
                    mapping["epoch"] = coordinate_system.epoch
                mappings.append(mapping)
        return mappings
[docs]
    def extract_timesys_mapping(self):
        """
        Create a mapping dictionary for each ``TIMESYS`` element found
        in the first VOTable resource.
        .. note::
            the ``origin`` attribute is not supported yet
        Returns
        -------
        [dict]
            Array of dictionaries which items can be used as input parameter for
            :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_simple_time_frame`
        """
        mappings = []
        for resource in self._votable.resources:
            for time_system in resource.time_systems:
                mapping = {}
                if not self._check_votable_head_element(time_system.timescale):
                    logging.warning(f"Not valid TIMESYS found: ignored in MIVOT: {time_system}")
                    continue
                mapping["timescale"] = time_system.timescale
                if self._check_votable_head_element(time_system.refposition):
                    mapping["refPosition"] = time_system.refposition
                mappings.append(mapping)
        return mappings
[docs]
    def extract_epochposition_mapping(self):
        """
        Analyze the FIELD UCD-s to infer a data mapping to the EpochPosition class.
        This mapping covers the 6 parameters with the Epoch and their errors.
        The correlation part is not covered since there is no specific UCD for this.
        The UCD-s accepted for each parameter are defined in `pyvo.mivot.glossary`.
        The error classes are hard-coded as the most likely types.
        - PErrorSym2D for 2D parameters
        - PErrorSym1D for 1D parameters
        Returns
        -------
        (dict, dict)
            A mapping proposal for the EpochPosiion + errors that can be used as input parameter
            by :py:meth:`pyvo.mivot.writer.InstancesFromModels.add_mango_epoch_position`.
        """
        def _check_ucd(mapping_entry, ucd, mapping):
            """
            Inner function checking that mapping_entry matches with ucd according to
            `pyvo.mivot.glossary`
            """
            if mapping_entry in mapping:
                return False
            dict_entry = getattr(EpochPositionAutoMapping, mapping_entry)
            if isinstance(dict_entry, list):
                return ucd in dict_entry
            else:
                return ucd.startswith(dict_entry)
        def _check_obs_date(field):
            """ check if the field can be interpreted as a value date time
            This algorithm is a bit specific for Vizier CS
            """
            xtype = field.xtype
            unit = field.unit
            representation = None
            if xtype == "timestamp" or unit == "'Y:M:D'" or unit == "'Y-M-D'":
                representation = "iso"
            # let's assume that dates expressed as days are MJD
            elif xtype == "mjd" or unit == "d":
                representation = "mjd"
            if representation is None and unit == "year":
                representation = "year"
            if representation is not None:
                field_ref = field.ID if field.ID is not None else field.name
                return {"dateTime": field_ref, "representation": representation}
            return None
        table = self._votable.get_first_table()
        fields = table.fields
        mapping = {}
        error_mapping = {}
        for field in fields:
            ucd = field.ucd
            for mapping_entry in Roles.EpochPosition:
                if _check_ucd(mapping_entry, ucd, mapping) is True:
                    if mapping_entry == "obsDate":
                        if (obs_date_mapping := _check_obs_date(field)) is not None:
                            mapping[mapping_entry] = obs_date_mapping
                    else:
                        mapping[mapping_entry] = field.ID if field.ID is not None else field.name
                    # Once we got a parameter mapping, we look for its associated error
                    # This nested loop makes sure we never have error without value
                    for err_field in fields:
                        err_ucd = err_field.ucd
                        # We assume the error UCDs are the same the these of the
                        # related quantities but prefixed with "stat.error;" and without "meta.main" qualifier
                        if err_ucd == ("stat.error;" + ucd.replace(";meta.main", "")):
                            param_mapping = err_field.ID if err_field.ID is not None else err_field.name
                            if mapping_entry == "parallax":
                                error_mapping[mapping_entry] = {"class": "PErrorSym1D",
                                                                "sigma": param_mapping}
                            elif mapping_entry == "radialVelocity":
                                error_mapping[mapping_entry] = {"class": "PErrorSym1D",
                                                                "sigma": param_mapping}
                            elif mapping_entry == "longitude":
                                if "position" in error_mapping:
                                    error_mapping["position"]["sigma1"] = param_mapping
                                else:
                                    error_mapping["position"] = {"class": "PErrorSym2D",
                                                                 "sigma1": param_mapping}
                            elif mapping_entry == "latitude":
                                if "position" in error_mapping:
                                    error_mapping["position"]["sigma2"] = param_mapping
                                else:
                                    error_mapping["position"] = {"class": "PErrorSym2D",
                                                                 "sigma2": param_mapping}
                            elif mapping_entry == "pmLongitude":
                                if "properMotion" in error_mapping:
                                    error_mapping["properMotion"]["sigma1"] = param_mapping
                                else:
                                    error_mapping["properMotion"] = {"class": "PErrorSym2D",
                                                                     "sigma1": param_mapping}
                            elif mapping_entry == "pmLatitude":
                                if "properMotion" in error_mapping:
                                    error_mapping["properMotion"]["sigma2"] = param_mapping
                                else:
                                    error_mapping["properMotion"] = {"class": "PErrorSym2D",
                                                                     "sigma2": param_mapping}
        return mapping, error_mapping