<reponame>loudnate/openaps-predict<filename>openapscontrib/predict/__init__.py
"""
predict - tools for predicting glucose trends


"""
from .version import __version__

import ast
import argparse
from datetime import datetime, timedelta
from dateutil.parser import parse
from dateutil.tz import gettz
import json
import os

from openaps.uses.use import Use

from predict import Schedule
from predict import calculate_momentum_effect
from predict import calculate_carb_effect
from predict import calculate_cob
from predict import calculate_glucose_from_effects
from predict import calculate_insulin_effect
from predict import calculate_iob
from predict import future_glucose
from predict import glucose_data_tuple


# set_config is needed by openaps for all vendors.
# set_config is used by `device add` commands so save any needed
# information.
# See the medtronic builtin module for an example of how to use this
# to save needed information to establish sessions (serial numbers,
# etc).
def set_config(args, device):
    # no special config
    return


# display_device allows our custom vendor implementation to include
# special information when displaying information about a device using
# our plugin as a vendor.
def display_device(device):
    # no special information needed to run
    return ''


# openaps calls get_uses to figure out how how to use a device using
# agp as a vendor.  Return a list of classes which inherit from Use,
# or are compatible with it:
def get_uses(device, config):
    return [
        glucose,
        glucose_from_effects,
        glucose_momentum_effect,
        scheiner_carb_effect,
        scheiner_cob,
        walsh_insulin_effect,
        walsh_iob
    ]


def _opt_date(timestamp):
    """Parses a date string if defined

    :param timestamp: The date string to parse
    :type timestamp: basestring
    :return: A datetime object if a timestamp was specified
    :rtype: datetime.datetime|NoneType
    """
    if timestamp:
        return parse(timestamp)


def _json_file(filename):
    return json.load(argparse.FileType('r')(filename))


def _opt_json_file(filename):
    """Parses a filename as JSON input if defined

    :param filename: The path to the file to parse
    :type filename: basestring
    :return: A decoded JSON object if a filename was specified
    :rtype: dict|list|NoneType
    """
    if filename:
        return _json_file(filename)


def make_naive(value, timezone=None):
    """
    Makes an aware datetime.datetime naive in a given time zone.
    """
    if timezone is None:
        timezone = gettz()
    # If `value` is naive, astimezone() will raise a ValueError,
    # so we don't need to perform a redundant check.
    value = value.astimezone(timezone)
    if hasattr(timezone, 'normalize'):
        # This method is available for pytz time zones.
        value = timezone.normalize(value)
    return value.replace(tzinfo=None)


# noinspection PyPep8Naming
class glucose_momentum_effect(Use):
    """Predict short-term trend of glucose

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'glucose',
            help='JSON-encoded glucose data file in reverse-chronological order'
        )

        parser.add_argument(
            '--prediction-time',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The total length of forward trend extrapolation in minutes. Defaults to 30.'
        )

        parser.add_argument(
            '--calibrations',
            nargs=argparse.OPTIONAL,
            help='JSON-encoded sensor calibrations data file in reverse-chronological order'
        )

    def get_params(self, args):
        params = super(glucose_momentum_effect, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('glucose', 'prediction_time', 'calibrations'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        args = (
            _json_file(params['glucose']),
        )

        kwargs = dict()

        if params.get('prediction_time'):
            kwargs.update(prediction_time=int(params['prediction_time']))

        if params.get('calibrations'):
            kwargs.update(recent_calibrations=_opt_json_file(params['calibrations']) or ())

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_momentum_effect(*args, **kwargs)


# noinspection PyPep8Naming
class scheiner_carb_effect(Use):
    """Predict carb effect on glucose, using the Scheiner GI curve

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'history',
            help='JSON-encoded pump history data file, normalized by openapscontrib.mmhistorytools'
        )

        parser.add_argument(
            '--carb-ratios',
            help='JSON-encoded carb ratio schedule file'
        )

        parser.add_argument(
            '--insulin-sensitivities',
            help='JSON-encoded insulin sensitivities schedule file'
        )

        parser.add_argument(
            '--absorption-time',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The total length of carbohydrate absorption in minutes'
        )

        parser.add_argument(
            '--absorption-delay',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The delay time between a dosing event and when absorption begins'
        )

    def get_params(self, args):
        params = super(scheiner_carb_effect, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('history', 'carb_ratios', 'insulin_sensitivities', 'absorption_time', 'absorption_delay'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        args = (
            _json_file(params['history']),
            Schedule(_json_file(params['carb_ratios'])['schedule']),
            Schedule(_json_file(params['insulin_sensitivities'])['sensitivities'])
        )

        kwargs = dict()

        if params.get('absorption_time'):
            kwargs.update(absorption_duration=int(params.get('absorption_time')))

        if params.get('absorption_delay'):
            kwargs.update(absorption_delay=int(params.get('absorption_delay')))

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_carb_effect(*args, **kwargs)


# noinspection PyPep8Naming
class scheiner_cob(Use):
    """Predict unabsorbed carbohydrates, using the Scheiner GI curve

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'history',
            help='JSON-encoded pump history data file, normalized by openapscontrib.mmhistorytools'
        )

        parser.add_argument(
            '--absorption-time',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The total length of carbohydrate absorption in minutes'
        )

        parser.add_argument(
            '--absorption-delay',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The delay time between a dosing event and when absorption begins'
        )

    def get_params(self, args):
        params = super(scheiner_cob, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('history', 'absorption_time', 'absorption_delay'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        args = (
            _json_file(params['history']),
        )

        kwargs = dict()

        if params.get('absorption_time'):
            kwargs.update(absorption_duration=int(params.get('absorption_time')))

        if params.get('absorption_delay'):
            kwargs.update(absorption_delay=int(params.get('absorption_delay')))

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_cob(*args, **kwargs)


# noinspection PyPep8Naming
class walsh_insulin_effect(Use):
    """Predict insulin effect on glucose, using Walsh's IOB algorithm

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'history',
            help='JSON-encoded pump history data file, normalized by openapscontrib.mmhistorytools'
        )

        parser.add_argument(
            '--settings',
            nargs=argparse.OPTIONAL,
            help='JSON-encoded pump settings file, optional if --insulin-action-curve is set'
        )

        parser.add_argument(
            '--insulin-action-curve',
            nargs=argparse.OPTIONAL,
            type=float,
            choices=range(3, 7),
            help='Insulin action curve, optional if --settings is set'
        )

        parser.add_argument(
            '--insulin-sensitivities',
            help='JSON-encoded insulin sensitivities schedule file'
        )

        parser.add_argument(
            '--basal-dosing-end',
            nargs=argparse.OPTIONAL,
            help='The timestamp at which temp basal dosing should be assumed to end, '
                 'as a JSON-encoded pump clock file'
        )

        parser.add_argument(
            '--absorption-delay',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The delay time between a dosing event and when absorption begins'
        )

    def get_params(self, args):
        params = super(walsh_insulin_effect, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('history', 'settings', 'insulin_action_curve', 'insulin_sensitivities', 'basal_dosing_end', 'absorption_delay'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        args = (
            _json_file(params['history']),
            int(params.get('insulin_action_curve', None) or
                _opt_json_file(params.get('settings', ''))['insulin_action_curve']),
            Schedule(_json_file(params['insulin_sensitivities'])['sensitivities'])
        )

        kwargs = dict(
            basal_dosing_end=_opt_date(_opt_json_file(params.get('basal_dosing_end')))
        )

        if params.get('absorption_delay'):
            kwargs.update(absorption_delay=int(params.get('absorption_delay')))

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_insulin_effect(*args, **kwargs)


# noinspection PyPep8Naming
class walsh_iob(Use):
    """Predict IOB using Walsh's algorithm

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'history',
            help='JSON-encoded pump history data file, normalized by openapscontrib.mmhistorytools'
        )

        parser.add_argument(
            '--settings',
            nargs=argparse.OPTIONAL,
            help='JSON-encoded pump settings file, optional if --insulin-action-curve is set'
        )

        parser.add_argument(
            '--insulin-action-curve',
            nargs=argparse.OPTIONAL,
            type=float,
            choices=range(3, 7),
            help='Insulin action curve, optional if --settings is set'
        )

        parser.add_argument(
            '--basal-dosing-end',
            nargs=argparse.OPTIONAL,
            help='The timestamp at which temp basal dosing should be assumed to end, '
                 'as a JSON-encoded pump clock file'
        )

        parser.add_argument(
            '--absorption-delay',
            type=int,
            nargs=argparse.OPTIONAL,
            help='The delay time between a dosing event and when absorption begins'
        )

        parser.add_argument(
            '--start-at',
            nargs=argparse.OPTIONAL,
            help='File containing the timestamp at which to truncate the beginning of the output, '
                 'as a JSON-encoded ISO date'
        )

        parser.add_argument(
            '--end-at',
            nargs=argparse.OPTIONAL,
            help='File containing the timestamp at which to truncate the end of the output, '
                 'as a JSON-encoded ISO date'
        )

    def get_params(self, args):
        params = super(walsh_iob, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('history',
                    'settings',
                    'insulin_action_curve',
                    'basal_dosing_end',
                    'absorption_delay',
                    'start_at',
                    'end_at'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        args = (
            _json_file(params['history']),
            int(params.get('insulin_action_curve', None) or
                _opt_json_file(params.get('settings', ''))['insulin_action_curve'])
        )

        kwargs = dict(
            basal_dosing_end=_opt_date(_opt_json_file(params.get('basal_dosing_end'))),
            start_at=_opt_date(_opt_json_file(params.get('start_at'))),
            end_at=_opt_date(_opt_json_file(params.get('end_at')))
        )

        if params.get('absorption_delay'):
            kwargs.update(absorption_delay=int(params.get('absorption_delay')))

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_iob(*args, **kwargs)


# noinspection PyPep8Naming
class glucose_from_effects(Use):
    """Predict glucose from one or more effect schedules

    """
    @staticmethod
    def configure_app(app, parser):
        parser.add_argument(
            'effects',
            nargs=argparse.ONE_OR_MORE,
            help='JSON-encoded effect schedules data files'
        )

        parser.add_argument(
            '--glucose',
            help='JSON-encoded glucose data file in reverse-chronological order'
        )

        parser.add_argument(
            '--momentum',
            help='JSON-encoded momentum effect schedule data file'
        )

    def get_params(self, args):
        params = super(glucose_from_effects, self).get_params(args)

        args_dict = dict(**args.__dict__)

        for key in ('effects', 'glucose', 'momentum'):
            value = args_dict.get(key)
            if value is not None:
                params[key] = value

        return params

    @staticmethod
    def get_program(params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        effect_files = params['effects']

        if isinstance(effect_files, str):
            effect_files = ast.literal_eval(effect_files)

        recent_glucose = _json_file(params['glucose'])

        if len(recent_glucose) > 0:
            glucose_file_time = datetime.fromtimestamp(os.path.getmtime(params['glucose']))
            last_glucose_datetime = parse(glucose_data_tuple(recent_glucose[0])[0])

            if last_glucose_datetime.utcoffset() is not None:
                last_glucose_datetime = make_naive(last_glucose_datetime)

            assert abs(glucose_file_time - last_glucose_datetime) < timedelta(minutes=15), \
                'Glucose data is more than 15 minutes old'

        effects = []

        for f in effect_files:
            file_time = datetime.fromtimestamp(os.path.getmtime(f))
            assert datetime.now() - file_time < timedelta(minutes=5), '{} is more than 5 minutes old'.format(f)

            effects.append(_json_file(f))

        args = (effects, recent_glucose)
        kwargs = {}

        momentum_file_name = params.get('momentum')
        if momentum_file_name:
            kwargs['momentum'] = _opt_json_file(params.get('momentum'))
            file_time = datetime.fromtimestamp(os.path.getmtime(params['momentum']))
            assert datetime.now() - file_time < timedelta(minutes=5), '{} is more than 5 minutes old'.format()

        return args, kwargs

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return calculate_glucose_from_effects(*args, **kwargs)


# noinspection PyPep8Naming
class glucose(Use):
    """Predict glucose. This is a convenience shortcut for insulin and carb effect prediction.

    """
    def configure_app(self, app, parser):
        """Define command arguments.

        Only primitive types should be used here to allow for serialization and partial application
        in via openaps-report.
        """
        parser.add_argument(
            'pump-history',
            help='JSON-encoded pump history data file, normalized by openapscontrib.mmhistorytools'
        )

        parser.add_argument(
            'glucose',
            help='JSON-encoded glucose data file in reverse-chronological order'
        )

        parser.add_argument(
            '--settings',
            nargs=argparse.OPTIONAL,
            help='JSON-encoded pump settings file, optional if --insulin-action-curve is set'
        )

        parser.add_argument(
            '--insulin-action-curve',
            nargs=argparse.OPTIONAL,
            type=float,
            choices=range(3, 7),
            help='Insulin action curve, optional if --settings is set'
        )

        parser.add_argument(
            '--insulin-sensitivities',
            help='JSON-encoded insulin sensitivities schedule file'
        )

        parser.add_argument(
            '--carb-ratios',
            help='JSON-encoded carb ratio schedule file'
        )

        parser.add_argument(
            '--basal-dosing-end',
            nargs=argparse.OPTIONAL,
            help='The timestamp at which temp basal dosing should be assumed to end, '
                 'as a JSON-encoded pump clock file'
        )

    def get_params(self, args):
        params = dict(**args.__dict__)

        if params.get('settings') is None:
            params.pop('settings', None)

        if params.get('insulin_action_curve') is None:
            params.pop('insulin_action_curve', None)

        if params.get('basal_dosing_end') is None:
            params.pop('basal_dosing_end', None)

        params.pop('use', None)
        params.pop('action', None)
        params.pop('report', None)

        return params

    def get_program(self, params):
        """Parses params into history parser constructor arguments

        :param params:
        :type params: dict
        :return:
        :rtype: tuple(list, dict)
        """
        pump_history_file_time = datetime.fromtimestamp(os.path.getmtime(params['pump-history']))
        assert datetime.now() - pump_history_file_time < timedelta(minutes=5), 'History data is more than 5 minutes old'

        recent_glucose = _json_file(params['glucose'])

        if len(recent_glucose) > 0:
            glucose_file_time = datetime.fromtimestamp(os.path.getmtime(params['glucose']))
            last_glucose_datetime = parse(glucose_data_tuple(recent_glucose[0])[0])

            if last_glucose_datetime.utcoffset() is not None:
                last_glucose_datetime = make_naive(last_glucose_datetime)

            assert abs(glucose_file_time - last_glucose_datetime) < timedelta(minutes=15), \
                'Glucose data is more than 15 minutes old'

        args = (
            _json_file(params['pump-history']),
            recent_glucose,
            int(params.get('insulin_action_curve', None) or
                _opt_json_file(params.get('settings', ''))['insulin_action_curve']),
            Schedule(_json_file(params['insulin_sensitivities'])['sensitivities']),
            Schedule(_json_file(params['carb_ratios'])['schedule']),
        )

        return args, dict(basal_dosing_end=_opt_date(_opt_json_file(params.get('basal_dosing_end'))))

    def main(self, args, app):
        args, kwargs = self.get_program(self.get_params(args))

        return future_glucose(*args, **kwargs)
