<NME> ar2.js
<BEF> 'use strict';

var _ = require('lodash');
var times = require('../times');
var moment = require('moment');

var BG_REF = 140; //Central tendency
var BG_MIN = 36; //Not 39, but why?
var BG_MAX = 400;
var WARN_THRESHOLD = 0.05;
var URGENT_THRESHOLD = 0.10;

var AR = [-0.723, 1.716];

//TODO: move this to css
var AR2_COLOR = 'cyan';

function init (ctx) {
  var translate = ctx.language.translate;

  var ar2 = {
    name: 'ar2'
    , label: 'AR2'
    , pluginType: 'forecast'
  };

  function buildTitle (prop, sbx) {
    var rangeLabel = prop.eventName ? sbx.translate(prop.eventName, { ci: true }).toUpperCase() : sbx.translate('Check BG');
    var title = sbx.levels.toDisplay(prop.level) + ', ' + rangeLabel;

    var sgv = sbx.lastScaledSGV();
    if (sgv > sbx.scaleMgdl(sbx.settings.thresholds.bgTargetBottom) && sgv < sbx.scaleMgdl(sbx.settings.thresholds.bgTargetTop)) {
      title += ' ' + sbx.translate('predicted');
    }
    return title;
  }

  ar2.setProperties = function setProperties (sbx) {
    sbx.offerProperty('ar2', function setAR2 () {

      var prop = {
        forecast: ar2.forecast(sbx)
      };

      var result = checkForecast(prop.forecast, sbx);

      if (result) {
        prop.level = result.level;
        prop.eventName = result.eventName;
      }

      var predicted = prop.forecast && prop.forecast.predicted;
      var scaled = predicted && _.map(predicted, function(p) { return sbx.scaleEntry(p) });

      if (scaled && scaled.length >= 3) {
        prop.displayLine = 'BG 15m: ' + scaled[2] + ' ' + sbx.unitsLabel;
      }

      return prop;
    });
  };

  ar2.checkNotifications = function checkNotifications (sbx) {
    if (sbx.time - sbx.lastSGVMills() > times.mins(10).msecs) {
      return;
    }

    var prop = sbx.properties.ar2;

    if (prop && prop.level) {
      const notify = {
        level: prop.level
        , title: buildTitle(prop, sbx)
        , message: sbx.buildDefaultMessage()
        , eventName: prop.eventName
        , pushoverSound: pushoverSound(prop, sbx.levels)
        , plugin: ar2
        , debug: buildDebug(prop, sbx)
      };
      sbx.notifications.requestNotify(notify);
    }
  };

  ar2.forecast = function forecast (sbx) {

    var result = {
      predicted: []
      , avgLoss: 0
    };
      lines.splice(
        usingRaw ? 2 : 1 //if there's raw there will be an extra line after bg now
        , 0
        , [usingRaw ? 'Raw BG' : 'BG', '15m:', sbx.scaleBg(predicted[2]), sbx.unitsLabel].join(' ')
      );

      var message = lines.join('\n');
      new Array(6) //only 6 points are used for calculating avgLoss
      , pushPoint
      , initAR2(sbx)
    ).points;

    // compute current loss
    var size = Math.min(result.predicted.length - 1, 6);
    for (var j = 0; j <= size; j++) {
      result.avgLoss += 1 / size * Math.pow(log10(result.predicted[j].mgdl / 120), 2);
    }

    return result;
  };

  ar2.updateVisualisation = function updateVisualisation (sbx) {
    sbx.pluginBase.addForecastPoints(ar2.forecastCone(sbx), { type: 'ar2', label: 'AR2 Forecast' });
  };

  ar2.forecastCone = function forecastCone (sbx) {

    if (!okToForecast(sbx)) {
      return [];
    }

    var coneFactor = getConeFactor(sbx);

    function pushConePoints (result, step) {
      var next = incrementAR2(result);

      //offset from points so they are at a unique time
      if (coneFactor > 0) {
        next.points.push(ar2Point(next
          , { offset: 2000, coneFactor: -coneFactor, step: step }
        ));
      }

      next.points.push(ar2Point(next
        , { offset: 4000, coneFactor: coneFactor, step: step }
      ));

      return next;
    }

    //fold left over cone steps, each time update the accumulator
    var result = _.reduce(
      [0.020, 0.041, 0.061, 0.081, 0.099, 0.116, 0.132, 0.146, 0.159, 0.171, 0.182, 0.192, 0.201]
      , pushConePoints
      , initAR2(sbx)
    );

    return result.points;
  };

  function virtAsstAr2Handler (next, slots, sbx) {
    var predicted = _.get(sbx, 'properties.ar2.forecast.predicted');
    if (predicted) {
      var forecast = predicted;
      var max = forecast[0].mgdl;
      var min = forecast[0].mgdl;
      var maxForecastMills = forecast[0].mills;
      for (var i = 1, len = forecast.length; i < len; i++) {
        if (forecast[i].mgdl > max) {
          max = forecast[i].mgdl;
        }
        if (forecast[i].mgdl < min) {
          min = forecast[i].mgdl;
        }
        if (forecast[i].mills > maxForecastMills) {
          maxForecastMills = forecast[i].mills;
        }
      }
      var response = '';
      if (min === max) {
        response = translate('virtAsstAR2ForecastAround', {
          params: [
            max
            , moment(maxForecastMills).from(moment(sbx.time))
          ]
        });
      } else {
        response = translate('virtAsstAR2ForecastBetween', {
          params: [
            min
            , max
            , moment(maxForecastMills).from(moment(sbx.time))
          ]
        });
      }
      next(translate('virtAsstTitleAR2Forecast'), response);
    } else {
      next(translate('virtAsstTitleAR2Forecast'), translate('virtAsstUnknown'));
    }
  }

  ar2.virtAsst = {
    intentHandlers: [{
      intent: 'MetricNow'
      , metrics: ['ar2 forecast', 'forecast']
      , intentHandler: virtAsstAr2Handler
    }]
  };

  return ar2;
}

function checkForecast (forecast, sbx) {
  var result = undefined;

  if (forecast && forecast.avgLoss > URGENT_THRESHOLD) {
    result = { level: sbx.levels.URGENT };
  } else if (forecast && forecast.avgLoss > WARN_THRESHOLD) {
    result = { level: sbx.levels.WARN };
  }

  if (result) {
    result.forecast = forecast;
    result.eventName = selectEventType(result, sbx);
  }

  return result;
}

function selectEventType (prop, sbx) {
  var predicted = prop.forecast && _.map(prop.forecast.predicted, function(p) { return sbx.scaleEntry(p) });

  var in20mins = predicted && predicted.length >= 4 ? predicted[3] : undefined;

  //if not set to high or low the default eventType will be assumed
  var eventName = '';

  if (in20mins !== undefined) {
    if (sbx.settings.alarmHigh && in20mins > sbx.scaleMgdl(sbx.settings.thresholds.bgTargetTop)) {
      eventName = 'high';
    } else if (sbx.settings.alarmLow && in20mins < sbx.scaleMgdl(sbx.settings.thresholds.bgTargetBottom)) {
      eventName = 'low';
    }
  }

  return eventName;
}

function pushoverSound (prop, levels) {
  var sound;

  if (prop.level === levels.URGENT) {
    sound = 'persistent';
  } else if (prop.eventName === 'low') {
    sound = 'falling';
  } else if (prop.eventName === 'high') {
    sound = 'climb';
  }

  return sound;
}

function getConeFactor (sbx) {
  var value = Number(sbx.extendedSettings.coneFactor);
  if (isNaN(value) || value < 0) {
    value = 2;
  }
  return value;
}

function okToForecast (sbx) {

  var bgnow = sbx.properties.bgnow;
  var delta = sbx.properties.delta;

  if (!bgnow || !delta) {
    return false;
  }

  return bgnow.mean >= BG_MIN && delta.mean5MinsAgo && _.isNumber(delta.mean5MinsAgo);
}

function initAR2 (sbx) {
  var bgnow = sbx.properties.bgnow;
  var delta = sbx.properties.delta;
  var mean5MinsAgo = delta.mean5MinsAgo;

  return {
    forecastTime: bgnow.mills || sbx.time
    , points: []
    , prev: Math.log(mean5MinsAgo / BG_REF)
    , curr: Math.log(bgnow.mean / BG_REF)
  };
}

function incrementAR2 (result) {
  return {
    forecastTime: result.forecastTime + times.mins(5).msecs
    , points: result.points || []
    , prev: result.curr
    , curr: AR[0] * result.prev + AR[1] * result.curr
  };
}

function pushPoint (result) {
  var next = incrementAR2(result);

  next.points.push(ar2Point(
    next
    , { offset: 2000 }
  ));

  return next;
}

function ar2Point (next, options) {
  var step = options.step || 0;
  var coneFactor = options.coneFactor || 0;
  var offset = options.offset || 0;

  var mgdl = Math.round(BG_REF * Math.exp(
    next.curr + coneFactor * step
  ));

  return {
    mills: next.forecastTime + offset
    , mgdl: Math.max(BG_MIN, Math.min(BG_MAX, mgdl))
    , color: AR2_COLOR
  };
}

function buildDebug (prop, sbx) {
  return prop.forecast && {
    forecast: {
      avgLoss: prop.forecast.avgLoss
      , predicted: _.map(prop.forecast.predicted, function(p) { return sbx.scaleEntry(p) }).join(', ')
    }
  };
}

function log10 (val) { return Math.log(val) / Math.LN10; }

module.exports = init;

<MSG> Remove double-conversion of BG from AR2 reporting

<DFF> @@ -90,7 +90,7 @@ function init() {
       lines.splice(
         usingRaw ? 2 : 1 //if there's raw there will be an extra line after bg now
         , 0
-        , [usingRaw ? 'Raw BG' : 'BG', '15m:', sbx.scaleBg(predicted[2]), sbx.unitsLabel].join(' ')
+        , [usingRaw ? 'Raw BG' : 'BG', '15m:', predicted[2], sbx.unitsLabel].join(' ')
       );
 
       var message = lines.join('\n');

{'commit': "diff --git a/lib/plugins/ar2.js b/lib/plugins/ar2.js\nindex 2b7b1d20..3caf4ce7 100644\n--- a/lib/plugins/ar2.js\n+++ b/lib/plugins/ar2.js\n@@ -90,7 +90,7 @@ function init() {\n       lines.splice(\n         usingRaw ? 2 : 1 //if there's raw there will be an extra line after bg now\n         , 0\n-        , [usingRaw ? 'Raw BG' : 'BG', '15m:', sbx.scaleBg(predicted[2]), sbx.unitsLabel].join(' ')\n+        , [usingRaw ? 'Raw BG' : 'BG', '15m:', predicted[2], sbx.unitsLabel].join(' ')\n       );\n \n       var message = lines.join('\\n');\n", 'commit_subject': 'Remove double-conversion of BG from AR2 reporting', 'repo_name': 'nightscout/cgm-remote-monitor', 'committer': {'name': 'Sulka Haro', 'email': 'aa7c58e0cb185318c4071d470e6c8873eb795cdd@sulka.net', 'time_sec': '1435993077', 'tz_offset': '180', 'date': {'seconds': '1435993077'}}, 'commit_tree': '038bf21cbb9f935c847fe99f6071100c82b97d1d', 'commit_message': 'Remove double-conversion of BG from AR2 reporting\n', 'language_name': 'HTML', 'license': 'agpl-3.0', 'hunks': "@@ -90,7 +90,7 @@ function init() {\n       lines.splice(\n         usingRaw ? 2 : 1 //if there's raw there will be an extra line after bg now\n         , 0\n-        , [usingRaw ? 'Raw BG' : 'BG', '15m:', sbx.scaleBg(predicted[2]), sbx.unitsLabel].join(' ')\n+        , [usingRaw ? 'Raw BG' : 'BG', '15m:', predicted[2], sbx.unitsLabel].join(' ')\n       );\n \n       var message = lines.join('\\n');\n", 'addition_count': 1, 'deletion_count': 1, 'src_file': 'a/lib/plugins/ar2.js', 'tgt_file': 'b/lib/plugins/ar2.js', 'file_extension': '.js', 'before_file': "'use strict';\n\nvar _ = require('lodash');\nvar times = require('../times');\nvar moment = require('moment');\n\nvar BG_REF = 140; //Central tendency\nvar BG_MIN = 36; //Not 39, but why?\nvar BG_MAX = 400;\nvar WARN_THRESHOLD = 0.05;\nvar URGENT_THRESHOLD = 0.10;\n\nvar AR = [-0.723, 1.716];\n\n//TODO: move this to css\nvar AR2_COLOR = 'cyan';\n\nfunction init (ctx) {\n  var translate = ctx.language.translate;\n\n  var ar2 = {\n    name: 'ar2'\n    , label: 'AR2'\n    , pluginType: 'forecast'\n  };\n\n  function buildTitle (prop, sbx) {\n    var rangeLabel = prop.eventName ? sbx.translate(prop.eventName, { ci: true }).toUpperCase() : sbx.translate('Check BG');\n    var title = sbx.levels.toDisplay(prop.level) + ', ' + rangeLabel;\n\n    var sgv = sbx.lastScaledSGV();\n    if (sgv > sbx.scaleMgdl(sbx.settings.thresholds.bgTargetBottom) && sgv < sbx.scaleMgdl(sbx.settings.thresholds.bgTargetTop)) {\n      title += ' ' + sbx.translate('predicted');\n    }\n    return title;\n  }\n\n  ar2.setProperties = function setProperties (sbx) {\n    sbx.offerProperty('ar2', function setAR2 () {\n\n      var prop = {\n        forecast: ar2.forecast(sbx)\n      };\n\n      var result = checkForecast(prop.forecast, sbx);\n\n      if (result) {\n        prop.level = result.level;\n        prop.eventName = result.eventName;\n      }\n\n      var predicted = prop.forecast && prop.forecast.predicted;\n      var scaled = predicted && _.map(predicted, function(p) { return sbx.scaleEntry(p) });\n\n      if (scaled && scaled.length >= 3) {\n        prop.displayLine = 'BG 15m: ' + scaled[2] + ' ' + sbx.unitsLabel;\n      }\n\n      return prop;\n    });\n  };\n\n  ar2.checkNotifications = function checkNotifications (sbx) {\n    if (sbx.time - sbx.lastSGVMills() > times.mins(10).msecs) {\n      return;\n    }\n\n    var prop = sbx.properties.ar2;\n\n    if (prop && prop.level) {\n      const notify = {\n        level: prop.level\n        , title: buildTitle(prop, sbx)\n        , message: sbx.buildDefaultMessage()\n        , eventName: prop.eventName\n        , pushoverSound: pushoverSound(prop, sbx.levels)\n        , plugin: ar2\n        , debug: buildDebug(prop, sbx)\n      };\n      sbx.notifications.requestNotify(notify);\n    }\n  };\n\n  ar2.forecast = function forecast (sbx) {\n\n    var result = {\n      predicted: []\n      , avgLoss: 0\n    };\n      lines.splice(\n        usingRaw ? 2 : 1 //if there's raw there will be an extra line after bg now\n        , 0\n        , [usingRaw ? 'Raw BG' : 'BG', '15m:', sbx.scaleBg(predicted[2]), sbx.unitsLabel].join(' ')\n      );\n\n      var message = lines.join('\\n');\n      new Array(6) //only 6 points are used for calculating avgLoss\n      , pushPoint\n      , initAR2(sbx)\n    ).points;\n\n    // compute current loss\n    var size = Math.min(result.predicted.length - 1, 6);\n    for (var j = 0; j <= size; j++) {\n      result.avgLoss += 1 / size * Math.pow(log10(result.predicted[j].mgdl / 120), 2);\n    }\n\n    return result;\n  };\n\n  ar2.updateVisualisation = function updateVisualisation (sbx) {\n    sbx.pluginBase.addForecastPoints(ar2.forecastCone(sbx), { type: 'ar2', label: 'AR2 Forecast' });\n  };\n\n  ar2.forecastCone = function forecastCone (sbx) {\n\n    if (!okToForecast(sbx)) {\n      return [];\n    }\n\n    var coneFactor = getConeFactor(sbx);\n\n    function pushConePoints (result, step) {\n      var next = incrementAR2(result);\n\n      //offset from points so they are at a unique time\n      if (coneFactor > 0) {\n        next.points.push(ar2Point(next\n          , { offset: 2000, coneFactor: -coneFactor, step: step }\n        ));\n      }\n\n      next.points.push(ar2Point(next\n        , { offset: 4000, coneFactor: coneFactor, step: step }\n      ));\n\n      return next;\n    }\n\n    //fold left over cone steps, each time update the accumulator\n    var result = _.reduce(\n      [0.020, 0.041, 0.061, 0.081, 0.099, 0.116, 0.132, 0.146, 0.159, 0.171, 0.182, 0.192, 0.201]\n      , pushConePoints\n      , initAR2(sbx)\n    );\n\n    return result.points;\n  };\n\n  function virtAsstAr2Handler (next, slots, sbx) {\n    var predicted = _.get(sbx, 'properties.ar2.forecast.predicted');\n    if (predicted) {\n      var forecast = predicted;\n      var max = forecast[0].mgdl;\n      var min = forecast[0].mgdl;\n      var maxForecastMills = forecast[0].mills;\n      for (var i = 1, len = forecast.length; i < len; i++) {\n        if (forecast[i].mgdl > max) {\n          max = forecast[i].mgdl;\n        }\n        if (forecast[i].mgdl < min) {\n          min = forecast[i].mgdl;\n        }\n        if (forecast[i].mills > maxForecastMills) {\n          maxForecastMills = forecast[i].mills;\n        }\n      }\n      var response = '';\n      if (min === max) {\n        response = translate('virtAsstAR2ForecastAround', {\n          params: [\n            max\n            , moment(maxForecastMills).from(moment(sbx.time))\n          ]\n        });\n      } else {\n        response = translate('virtAsstAR2ForecastBetween', {\n          params: [\n            min\n            , max\n            , moment(maxForecastMills).from(moment(sbx.time))\n          ]\n        });\n      }\n      next(translate('virtAsstTitleAR2Forecast'), response);\n    } else {\n      next(translate('virtAsstTitleAR2Forecast'), translate('virtAsstUnknown'));\n    }\n  }\n\n  ar2.virtAsst = {\n    intentHandlers: [{\n      intent: 'MetricNow'\n      , metrics: ['ar2 forecast', 'forecast']\n      , intentHandler: virtAsstAr2Handler\n    }]\n  };\n\n  return ar2;\n}\n\nfunction checkForecast (forecast, sbx) {\n  var result = undefined;\n\n  if (forecast && forecast.avgLoss > URGENT_THRESHOLD) {\n    result = { level: sbx.levels.URGENT };\n  } else if (forecast && forecast.avgLoss > WARN_THRESHOLD) {\n    result = { level: sbx.levels.WARN };\n  }\n\n  if (result) {\n    result.forecast = forecast;\n    result.eventName = selectEventType(result, sbx);\n  }\n\n  return result;\n}\n\nfunction selectEventType (prop, sbx) {\n  var predicted = prop.forecast && _.map(prop.forecast.predicted, function(p) { return sbx.scaleEntry(p) });\n\n  var in20mins = predicted && predicted.length >= 4 ? predicted[3] : undefined;\n\n  //if not set to high or low the default eventType will be assumed\n  var eventName = '';\n\n  if (in20mins !== undefined) {\n    if (sbx.settings.alarmHigh && in20mins > sbx.scaleMgdl(sbx.settings.thresholds.bgTargetTop)) {\n      eventName = 'high';\n    } else if (sbx.settings.alarmLow && in20mins < sbx.scaleMgdl(sbx.settings.thresholds.bgTargetBottom)) {\n      eventName = 'low';\n    }\n  }\n\n  return eventName;\n}\n\nfunction pushoverSound (prop, levels) {\n  var sound;\n\n  if (prop.level === levels.URGENT) {\n    sound = 'persistent';\n  } else if (prop.eventName === 'low') {\n    sound = 'falling';\n  } else if (prop.eventName === 'high') {\n    sound = 'climb';\n  }\n\n  return sound;\n}\n\nfunction getConeFactor (sbx) {\n  var value = Number(sbx.extendedSettings.coneFactor);\n  if (isNaN(value) || value < 0) {\n    value = 2;\n  }\n  return value;\n}\n\nfunction okToForecast (sbx) {\n\n  var bgnow = sbx.properties.bgnow;\n  var delta = sbx.properties.delta;\n\n  if (!bgnow || !delta) {\n    return false;\n  }\n\n  return bgnow.mean >= BG_MIN && delta.mean5MinsAgo && _.isNumber(delta.mean5MinsAgo);\n}\n\nfunction initAR2 (sbx) {\n  var bgnow = sbx.properties.bgnow;\n  var delta = sbx.properties.delta;\n  var mean5MinsAgo = delta.mean5MinsAgo;\n\n  return {\n    forecastTime: bgnow.mills || sbx.time\n    , points: []\n    , prev: Math.log(mean5MinsAgo / BG_REF)\n    , curr: Math.log(bgnow.mean / BG_REF)\n  };\n}\n\nfunction incrementAR2 (result) {\n  return {\n    forecastTime: result.forecastTime + times.mins(5).msecs\n    , points: result.points || []\n    , prev: result.curr\n    , curr: AR[0] * result.prev + AR[1] * result.curr\n  };\n}\n\nfunction pushPoint (result) {\n  var next = incrementAR2(result);\n\n  next.points.push(ar2Point(\n    next\n    , { offset: 2000 }\n  ));\n\n  return next;\n}\n\nfunction ar2Point (next, options) {\n  var step = options.step || 0;\n  var coneFactor = options.coneFactor || 0;\n  var offset = options.offset || 0;\n\n  var mgdl = Math.round(BG_REF * Math.exp(\n    next.curr + coneFactor * step\n  ));\n\n  return {\n    mills: next.forecastTime + offset\n    , mgdl: Math.max(BG_MIN, Math.min(BG_MAX, mgdl))\n    , color: AR2_COLOR\n  };\n}\n\nfunction buildDebug (prop, sbx) {\n  return prop.forecast && {\n    forecast: {\n      avgLoss: prop.forecast.avgLoss\n      , predicted: _.map(prop.forecast.predicted, function(p) { return sbx.scaleEntry(p) }).join(', ')\n    }\n  };\n}\n\nfunction log10 (val) { return Math.log(val) / Math.LN10; }\n\nmodule.exports = init;\n", 'message': 'Remove double-conversion of BG from AR2 reporting\n', 'author_name': 'Sulka Haro', 'lang': 'js'}