diff --git a/webapp/content/js/composer_widgets.js b/webapp/content/js/composer_widgets.js index 8e464b512..3bcc64c51 100644 --- a/webapp/content/js/composer_widgets.js +++ b/webapp/content/js/composer_widgets.js @@ -1089,6 +1089,7 @@ function createFunctionsMenu() { {text: 'Square Root', handler: applyFuncToEach('squareRoot')}, {text: 'Time-adjusted Derivative', handler: applyFuncToEachWithInput('perSecond', "Please enter a maximum value if this metric is a wrapping counter (or just leave this blank)", {allowBlank: true})}, {text: 'Integral', handler: applyFuncToEach('integral')}, + {text: 'Integral by Interval', handler: applyFuncToEachWithInput('integralByInterval', 'Integral this metric with a reset every ___ (examples: 1d, 1h, 10min)', {quote: true})}, {text: 'Percentile Values', handler: applyFuncToEachWithInput('percentileOfSeries', "Please enter the percentile to use")}, {text: 'Non-negative Derivative', handler: applyFuncToEachWithInput('nonNegativeDerivative', "Please enter a maximum value if this metric is a wrapping counter (or just leave this blank)", {allowBlank: true})}, {text: 'Log', handler: applyFuncToEachWithInput('log', 'Please enter a base')}, diff --git a/webapp/graphite/render/functions.py b/webapp/graphite/render/functions.py index af7a9c625..af682d2ca 100644 --- a/webapp/graphite/render/functions.py +++ b/webapp/graphite/render/functions.py @@ -24,7 +24,7 @@ from graphite.logger import log from graphite.render.attime import parseTimeOffset, parseATTime from graphite.events import models -from graphite.util import epoch +from graphite.util import epoch, timestamp, deltaseconds # XXX format_units() should go somewhere else if environ.get('READTHEDOCS'): @@ -33,7 +33,6 @@ from graphite.render.glyph import format_units from graphite.render.datalib import TimeSeries - NAN = float('NaN') INF = float('inf') DAY = 86400 @@ -1121,6 +1120,47 @@ def integral(requestContext, seriesList): results.append(newSeries) return results + +def integralByInterval(requestContext, seriesList, intervalUnit): + """ + This will do the same as integral() funcion, except resetting the total to 0 + at the given time in the parameter "from" + Useful for finding totals per hour/day/week/.. + + Example: + + .. code-block:: none + + &target=integralByInterval(company.sales.perMinute, "1d")&from=midnight-10days + + This would start at zero on the left side of the graph, adding the sales each + minute, and show the evolution of sales per day during the last 10 days. + """ + intervalDuration = int(abs(deltaseconds(parseTimeOffset(intervalUnit)))) + startTime = int(timestamp(requestContext['startTime'])) + results = [] + for series in seriesList: + newValues = [] + currentTime = series.start # current time within series iteration + current = 0.0 # current accumulated value + for val in series: + # reset integral value if crossing an interval boundary + if (currentTime - startTime)/intervalDuration != (currentTime - startTime - series.step)/intervalDuration: + current = 0.0 + if val is None: + # keep previous value since val can be None when resetting current to 0.0 + newValues.append(current) + else: + current += val + newValues.append(current) + currentTime += series.step + newName = "integralByInterval(%s,'%s')" % (series.name, intervalUnit) + newSeries = TimeSeries(newName, series.start, series.end, series.step, newValues) + newSeries.pathExpression = newName + results.append(newSeries) + return results + + def nonNegativeDerivative(requestContext, seriesList, maxValue=None): """ Same as the derivative function above, but ignores datapoints that trend @@ -3523,6 +3563,7 @@ def pieMinimum(requestContext, series): 'pow': pow, 'perSecond': perSecond, 'integral': integral, + 'integralByInterval' : integralByInterval, 'nonNegativeDerivative': nonNegativeDerivative, 'log': logarithm, 'invert': invert, diff --git a/webapp/graphite/util.py b/webapp/graphite/util.py index 42aec838e..c03eedf5e 100644 --- a/webapp/graphite/util.py +++ b/webapp/graphite/util.py @@ -152,6 +152,10 @@ def timestamp(datetime): "Convert a datetime object into epoch time" return time.mktime( datetime.timetuple() ) +def deltaseconds(timedelta): + "Convert a timedelta object into seconds (same as timedelta.total_seconds() in Python 2.7+)" + return (timedelta.microseconds + (timedelta.seconds + timedelta.days * 24 * 3600) * 10**6) / 10**6 + # This whole song & dance is due to pickle being insecure # The SafeUnpickler classes were largely derived from # http://nadiana.com/python-pickle-insecure diff --git a/webapp/tests/test_functions.py b/webapp/tests/test_functions.py index b155cf408..cd507602a 100644 --- a/webapp/tests/test_functions.py +++ b/webapp/tests/test_functions.py @@ -1,12 +1,12 @@ import copy import math import pytz -from datetime import datetime from fnmatch import fnmatch from django.test import TestCase from django.conf import settings from mock import patch, call, MagicMock +from datetime import datetime from graphite.render.datalib import TimeSeries from graphite.render import functions @@ -60,6 +60,18 @@ def testGetPercentile(self): result = functions._getPercentile(series, 30) self.assertEqual(expected, result, 'For series index <%s> the 30th percentile ordinal is not %d, but %d ' % (index, expected, result)) + def test_integral(self): + seriesList = [TimeSeries('test', 0, 600, 60, [None, 1, 2, 3, 4, 5, None, 6, 7, 8])] + expected = [TimeSeries('integral(test)', 0, 600, 60, [None, 1, 3, 6, 10, 15, None, 21, 28, 36])] + result = functions.integral({}, seriesList) + self.assertEqual(expected, result, 'integral result incorrect') + + def test_integralByInterval(self): + seriesList = [TimeSeries('test', 0, 600, 60, [None, 1, 2, 3, 4, 5, None, 6, 7, 8])] + expected = [TimeSeries("integral(test,'2min')", 0, 600, 60, [0, 1, 2, 5, 4, 9, 0, 6, 7, 15])] + result = functions.integralByInterval({'startTime' : datetime(1970,1,1)}, seriesList, '2min') + self.assertEqual(expected, result, 'integralByInterval result incorrect %s %s' %(result, result[0])) + def test_n_percentile(self): seriesList = [] config = [