Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
xpd-mails-connect / xpd_mails_connect.py
Size: Mime:
__version__="0.2.1"

import datetime
import tempfile
import logging
from io import StringIO

import pandas as pd
from gmr_underway_connect.underway_data import UnderwayExpeditionDataHandler
from trident_helix4_parser.helix4_parser import TridentHelix4MessageParser

logger = logging.getLogger(__name__)

def get_xpd_mail_data(days_in_the_past=1):
    """
    Gets curret data (i.e. mails from today) from known expedition mailboxes
    :param days_in_the_past: Days in the past to search maisl for, default=1, i.e. today only
    :return: pandas dataframe suitable for import into geoserver
    """
    # add aditional mailboxes here if needed

    # DST disabled
    #dst_mc = DstMailsConnector()
    #df_dsc = dst_mc.get_data(days_in_the_past)

    argo_mc = ArgoRecoveryMailConnector()
    df_argo = argo_mc.get_data(days_in_the_past)

    return df_argo

class ArgoRecoveryMailConnector:
    """Handles mails sent by ARGO Float ARGO_01 (WMO: 7900561) during recovery mode"""

    xpd_mail_folder = 'XPD_EugenSeibold_ARGO_01'
    platform_shortname = 'ARGO_01'

    def __init__(self):
        self.xpd_h = UnderwayExpeditionDataHandler(self.xpd_mail_folder)

        self.mail_ids = []
        self.trident_helix_4_messages = {}

    def get_data(self, days_in_the_past:int) -> pd.DataFrame:
        """
        Extracts position data ARGO_01 from mails received by dm-data.
        :param days_in_the_past: Number if days in the past to consider when looking for mails
        :return: pandas dataframe suitable for import into geoserver
        """
        ret = None
        if days_in_the_past >= 0:  # allow passing -1 resulting in empty df for test purposes
            data = self.get_data_from_mails(days_in_the_past)
            ret = pd.DataFrame(data.values())
            ret['platform_shortname'] = ArgoRecoveryMailConnector.platform_shortname

        if ret is not None and len(ret) > 0:
            return ret
        else:  # no mails w/ attachment found in specified time period
            logger.warning(f'no mails with attachment found in the last {days_in_the_past} days. '
                           f'Returning empty dataset.')
            return pd.DataFrame(columns=['platform_shortname','obs_timestamp','lat', 'lon'])

    def get_data_from_mails(self, days_in_past=0):
        """
        Connects to mailserver, gets mails from mission folder, extracts and parses data string from each mail
        :param days_in_past: number of days to look in the past, default:0->all mails
        :return: dict with mail_id as key, data-dict as value
        """
        ret = {}
        all_textblocks = self.xpd_h.get_all_text_blocks(days_in_the_past=days_in_past)

        if not all_textblocks:
            logger.warning(f'Cannot get data string from mail, no mails found in the last {days_in_past} day(s) ')
            return {}

        for mail_id, textblocks in all_textblocks.items():
            for tb in textblocks:
                try:
                    data_dict = self._parse_data_string(tb)
                    assert 'obs_timestamp' in data_dict.keys()
                    assert 'lat' in data_dict.keys()
                    assert 'lon' in data_dict.keys()
                    ret[mail_id] = data_dict
                except:
                    # we hit a text block which cannot be parsed. Probably harmless.
                    pass

        return ret


    @staticmethod
    def _parse_data_string(log_str):
        """
        Parses content of data string returns dict
        :param log_str:
        :return:
        """
        ret = pd.read_csv(StringIO(log_str), header=None, names=['ignoreme_1',
                                                                 'obs_timestamp',
                                                                 'lat', 'lon',
                                                                 'ignoreme_2'])
        # drop unused
        ret.drop(['ignoreme_1', 'ignoreme_2'], inplace=True, axis=1)
        # convert to timestamp object
        ret['obs_timestamp'] = ret['obs_timestamp'].apply(ArgoRecoveryMailConnector._parse_timestamp)

        return ret.to_dict('records')[0]

    @staticmethod
    def _parse_timestamp(ts):
        """
        Parses timstamp string, returns datetime.datetime object
        :param ts:
        :return:
        """
        ret = datetime.datetime.strptime(ts, r'%m/%d/%Y %H:%M:%S')
        # add timezone info
        ret = ret.replace(tzinfo=datetime.timezone.utc)
        return ret

class DstMailsConnector:
    """Handles mails sent by drifting sediment trap"""

    xpd_mail_folder = 'XPD_M160_DST_01'
    platform_shortname = 'DST_01'

    # real world example of line in email:
    # > Time of Session (UTC): Thu Oct  3 14:52:49 2019
    # the double whitespace above seems to be a decoding artifact, is unpacked to '=A' in python:
    # > Time of Session (UTC): Thu Oct =A03 14:52:49 2019
    time_marker = 'Time of Session (UTC):'  # line w/ datetime starts w/ this str
    time_format = '%a %b %d %H:%M:%S %Y'

    def __init__(self):
        self.xpd_h = UnderwayExpeditionDataHandler(self.xpd_mail_folder)

        self.mail_ids = []
        self.trident_helix_4_messages = {}

    def get_data(self, days_in_the_past:int) -> pd.DataFrame:
        """
        Extracts position data for the Drifting Sediment Traf DST_01 from mails received by dm-data.
        :param days_in_the_past: Number if days in the past to consider when looking for mails
        :return: pandas dataframe suitable for import into geoserver
        """
        data = []
        if days_in_the_past >= 0:  # allow passing -1 resulting in empty df for test purposes
            all_messages = []
            for mail_id, message in self.get_trident_helix_4_messages(days_in_the_past).items():
                all_messages.append(message)

            for msg in all_messages:
                for loc in msg.locations:
                    data.append({
                                'platform_shortname': DstMailsConnector.platform_shortname,
                                'obs_timestamp': loc.timestamp,
                                'lat': loc.latitude,
                                'lon': loc.longitude
                                })

        ret = pd.DataFrame(data)
        if len(ret) > 0:
            return ret
        else:  # no mails w/ attachment found in specified time period
            logger.warning(f'no mails with attachment found in the last {days_in_the_past} days. '
                           f'Returning empty dataset.')
            return pd.DataFrame(columns=['platform_shortname','obs_timestamp','lat', 'lon'])

    def get_trident_helix_4_messages(self, days_in_past):
        """
        Parses date from  Mail body, detaches sbd files and parses them
        :param days_in_past: number of days in the past to consider
        :return: dict with mail_ids as key, TridentHelix4Message as value
        """
        dates = self.get_dates_from_mails(days_in_past)
        ret = {}
        udh = UnderwayExpeditionDataHandler(xpd_folder=DstMailsConnector.xpd_mail_folder)
        with tempfile.TemporaryDirectory() as tmpdirname:
            detached = udh.detach_data(days_in_past, tmpdirname)
            if not detached:  # no mails found
                return {}     # --> return empty result set

            for mail_id, detached_files in detached.items():
                for detached_file in detached_files:
                    if detached_file.endswith('.sbd'):
                        assert dates.get(mail_id), f'Did not find mail_id {mail_id} in dates from mail!'
                        date = dates.get(mail_id)
                        parser = TridentHelix4MessageParser(date=date)
                        ret[mail_id] = parser.parse_file(detached_file)
                        # attention, if there are more than one .sbd file attached, only the *last* one is kept
        return ret



    def get_dates_from_mails(self, days_in_past=0):
        """
        Connects to mailserver, gets mails from mission folder and extracts date from each mail
        :param days_in_past: number of days to look in the past, default:0->all mails
        :return: dict with mail_id as key, datetime.datetime object as value
        """
        ret = {}
        textblocks = self.xpd_h.get_all_text_blocks(days_in_the_past=days_in_past)

        if not textblocks:
            logger.warning(f'Cannot get dates from mail, no mails found in the last {days_in_past} day(s) ')
            return {}

        for mail_id, textblocks in textblocks.items():
            ret[mail_id] = self._parse_date(textblocks)

        return ret


    def _parse_date(self, texblocks: [str]) -> datetime.datetime:
        """
        Looks through passed list with texts to find and parse the date contained 
        :param texblocks: 
        :return: datetime.datetime object or None
        """
        # separate lines from text blocks, put in one large list
        lines = []
        for tb in texblocks:
            lines.extend(tb.splitlines())

        for line in lines:
            if DstMailsConnector.time_marker in line:
                # split
                date_substring = DstMailsConnector._split_date_substring(line)
                # clean up
                date_substring = DstMailsConnector._scrub_datestr(date_substring)
                # parse
                ret = DstMailsConnector._parse_date_substring(date_substring, DstMailsConnector.time_format)
                if ret:
                    return ret


    @staticmethod
    def _scrub_datestr(datestr):
        """
        Returns a cleaned copy of the suspected datestring
        :param datestr:
        :return:
        """
        date_substring = datestr.strip()
        date_substring = date_substring.replace('  ', ' ')
        date_substring = date_substring.replace('=A0', '')

        return date_substring

    @staticmethod
    def _split_date_substring(line):
        """Splits a line along the designated time marker, returning the last element which should be the datestring"""
        return line.split(DstMailsConnector.time_marker)[-1]

    @staticmethod
    def _parse_date_substring(substring:str, time_format:str) -> datetime.datetime:
        """
        Attempts to parse a date from a given string using the given format. Fails silently
        :param substring: the substring to parse
        :param time_format: the strptime format to use
        :return: datetime object or None
        """
        try:
            ret = datetime.datetime.strptime(substring, time_format)
            assert 2018 < ret.year, f'Date {datetime} not in expected time period'
            return ret
        except Exception:
            return None