UNPKG

lumenize

Version:

Illuminating the forest AND the trees in your data.

833 lines (773 loc) 50.1 kB
<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>The source code</title> <link href="../resources/prettify/prettify.css" type="text/css" rel="stylesheet" /> <script type="text/javascript" src="../resources/prettify/prettify.js"></script> <style type="text/css"> .highlight { display: block; background-color: #ddd; } </style> <script type="text/javascript"> function highlight() { document.getElementById(location.hash.replace(/#/, "")).className = "highlight"; } </script> </head> <body onload="prettyPrint(); highlight();"> <pre class="prettyprint lang-js">/* &lt;CoffeeScript&gt; # !TODO: Add drill-down support with uniqueIDField or maybe keepFacts = true OLAPCube = require(&#39;./OLAPCube&#39;).OLAPCube {utils, Time, Timeline} = require(&#39;tztime&#39;) functions = require(&#39;./functions&#39;).functions class TimeSeriesCalculator # implements iCalculator &lt;/CoffeeScript&gt; */ <span id='Lumenize-TimeSeriesCalculator-method-constructor'><span id='Lumenize-TimeSeriesCalculator-cfg-endBefore'><span id='Lumenize-TimeSeriesCalculator-cfg-startOn'><span id='Lumenize-TimeSeriesCalculator-cfg-projectionsConfig'><span id='Lumenize-TimeSeriesCalculator-cfg-deriveFieldsAfterSummary'><span id='Lumenize-TimeSeriesCalculator-cfg-summaryMetricsConfig'><span id='Lumenize-TimeSeriesCalculator-cfg-deriveFieldsOnOutput'><span id='Lumenize-TimeSeriesCalculator-cfg-deriveFieldsOnInput'><span id='Lumenize-TimeSeriesCalculator-cfg-metrics'><span id='Lumenize-TimeSeriesCalculator-cfg-workDayEndBefore'><span id='Lumenize-TimeSeriesCalculator-cfg-workDayStartOn'><span id='Lumenize-TimeSeriesCalculator-cfg-holidays'><span id='Lumenize-TimeSeriesCalculator-cfg-workDays'><span id='Lumenize-TimeSeriesCalculator-cfg-granularity'><span id='Lumenize-TimeSeriesCalculator-cfg-uniqueIDField'><span id='Lumenize-TimeSeriesCalculator-cfg-validToField'><span id='Lumenize-TimeSeriesCalculator-cfg-validFromField'><span id='Lumenize-TimeSeriesCalculator-cfg-tz'><span id='Lumenize-TimeSeriesCalculator'> /** </span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span> * @class Lumenize.TimeSeriesCalculator * This calculator is used to convert snapshot data into time series aggregations. * * Below are two examples of using the TimeSeriesCalculator. The first is a detailed example showing how you would create * a set of single-metric series (line, spline, or column). The second, is an example of creating a set of group-by series * (like you would use to create a stacked column or stacked area chart). You can mix and match these on the same chart, but * one type (a set of single-metric series versus a single group-by meta-series) typically dominates. * * ## Time-series example - a burn chart ## * * Let&#39;s start with a fairly large set of snapshots and create a set of series for a burn (up/down) chart. * * lumenize = require(&#39;../&#39;) * {TimeSeriesCalculator, Time} = lumenize * * snapshotsCSV = [ * [&quot;ObjectID&quot;, &quot;_ValidFrom&quot;, &quot;_ValidTo&quot;, &quot;ScheduleState&quot;, &quot;PlanEstimate&quot;, &quot;TaskRemainingTotal&quot;, &quot;TaskEstimateTotal&quot;], * * [1, &quot;2010-10-10T15:00:00.000Z&quot;, &quot;2011-01-02T13:00:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 15 , 15], * * [1, &quot;2011-01-02T13:00:00.000Z&quot;, &quot;2011-01-02T15:10:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 15 , 15], * [1, &quot;2011-01-02T15:10:00.000Z&quot;, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;In progress&quot; , 5 , 20 , 15], * [2, &quot;2011-01-02T15:00:00.000Z&quot;, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 3 , 5 , 5], * [3, &quot;2011-01-02T15:00:00.000Z&quot;, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 12 , 12], * * [2, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;In progress&quot; , 3 , 5 , 5], * [3, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 12 , 12], * [4, &quot;2011-01-03T15:00:00.000Z&quot;, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 15 , 15], * [1, &quot;2011-01-03T15:10:00.000Z&quot;, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;In progress&quot; , 5 , 12 , 15], * * [1, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;Accepted&quot; , 5 , 0 , 15], * [2, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;In test&quot; , 3 , 1 , 5], * [3, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;2011-01-05T15:00:00.000Z&quot;, &quot;In progress&quot; , 5 , 10 , 12], * [4, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 5 , 15 , 15], * [5, &quot;2011-01-04T15:00:00.000Z&quot;, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 2 , 4 , 4], * * [3, &quot;2011-01-05T15:00:00.000Z&quot;, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;In test&quot; , 5 , 5 , 12], * * [1, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;Released&quot; , 5 , 0 , 15], * [2, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;Accepted&quot; , 3 , 0 , 5], * [4, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;In progress&quot; , 5 , 7 , 15], * [5, &quot;2011-01-06T15:00:00.000Z&quot;, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;Ready to pull&quot;, 2 , 4 , 4], * * [1, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;9999-01-01T00:00:00.000Z&quot;, &quot;Released&quot; , 5 , 0 , 15], * [2, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;9999-01-01T00:00:00.000Z&quot;, &quot;Released&quot; , 3 , 0 , 5], * [3, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;9999-01-01T00:00:00.000Z&quot;, &quot;Accepted&quot; , 5 , 0 , 12], * [4, &quot;2011-01-07T15:00:00.000Z&quot;, &quot;9999-01-01T00:00:00.000Z&quot;, &quot;In test&quot; , 5 , 3 , 15] # Note: ObjectID 5 deleted * ] * * snapshots = lumenize.csvStyleArray_To_ArrayOfMaps(snapshotsCSV) * * Let&#39;s add our first aggregation specification. You can add virtual fields to the input rows by providing your own callback function. * * deriveFieldsOnInput = [ * {as: &#39;PercentRemaining&#39;, f: (row) -&gt; 100 * row.TaskRemainingTotal / row.TaskEstimateTotal } * ] * * You can have as many of these derived fields as you wish. They are calculated in order to it&#39;s OK to use an earlier * derived field when calculating a later one. * * Next, we use the native fields in the snapshots, plus our derived field above to calculate most of the chart * series. Sums and counts are bread and butter, but all Lumenize.functions functions are supported (standardDeviation, * median, percentile coverage, etc.) and Lumenize includes some functions specifically well suited to burn chart * calculations (filteredSum, and filteredCount) as we shall now demonstrate. * * acceptedValues = [&#39;Accepted&#39;, &#39;Released&#39;] * * metrics = [ * {as: &#39;StoryCountBurnUp&#39;, f: &#39;filteredCount&#39;, filterField: &#39;ScheduleState&#39;, filterValues: acceptedValues}, * {as: &#39;StoryUnitBurnUp&#39;, field: &#39;PlanEstimate&#39;, f: &#39;filteredSum&#39;, filterField: &#39;ScheduleState&#39;, filterValues: acceptedValues}, * {as: &#39;StoryUnitScope&#39;, field: &#39;PlanEstimate&#39;, f: &#39;sum&#39;}, * {as: &#39;StoryCountScope&#39;, f: &#39;count&#39;}, * {as: &#39;TaskUnitBurnDown&#39;, field: &#39;TaskRemainingTotal&#39;, f: &#39;sum&#39;}, * {as: &#39;TaskUnitScope&#39;, field: &#39;TaskEstimateTotal&#39;, f: &#39;sum&#39;}, * {as: &#39;MedianPercentRemaining&#39;, field: &#39;PercentRemaining&#39;, f: &#39;median&#39;} * ] * * Let&#39;s break this down. The first series uses a `filteredCount` function. What this says is &quot;count the number of items * where the ScheduleState is either &#39;Accepted&#39; or &#39;Released&#39; and store that in a series named &#39;StoryCountBurnUp&#39;. The * second series is very similar but instead of counting, we are summing the PlanEstimate field and sticking it in * the StoryUnitBurnUp series. The next four series are simple sums or counts (no filtering) and the final series * is a gratuitous use of the &#39;median&#39; function least you forget that it can do more than counts and sums. * * Next, we specify the summary metrics for the chart. We&#39;re not really interested in displaying any summary metrics for * this chart but we need to calculate the max values of two of the existing series in order to add the two ideal line series. * Notice how the summary metric for TaskUnitBurnDown_max_index uses an earlier summary metric. They are calculated * in order and made avalable in the scope of the callback function to enable this. * * summaryMetricsConfig = [ * {field: &#39;TaskUnitScope&#39;, f: &#39;max&#39;}, * {field: &#39;TaskUnitBurnDown&#39;, f: &#39;max&#39;}, * {as: &#39;TaskUnitBurnDown_max_index&#39;, f: (seriesData, summaryMetrics) -&gt; * for row, index in seriesData * if row.TaskUnitBurnDown is summaryMetrics.TaskUnitBurnDown_max * return index * } * ] * * The calculations from the summary metrics above are passed into the calculations for &#39;deriveFieldsAfterSummary&#39;. * Here is where we calculate two alternatives for the burn down ideal line. * * deriveFieldsAfterSummary = [ * {as: &#39;Ideal&#39;, f: (row, index, summaryMetrics, seriesData) -&gt; * max = summaryMetrics.TaskUnitScope_max * increments = seriesData.length - 1 * incrementAmount = max / increments * return Math.floor(100 * (max - index * incrementAmount)) / 100 * }, * {as: &#39;Ideal2&#39;, f: (row, index, summaryMetrics, seriesData) -&gt; * if index &lt; summaryMetrics.TaskUnitBurnDown_max_index * return null * else * max = summaryMetrics.TaskUnitBurnDown_max * increments = seriesData.length - 1 - summaryMetrics.TaskUnitBurnDown_max_index * incrementAmount = max / increments * return Math.floor(100 * (max - (index - summaryMetrics.TaskUnitBurnDown_max_index) * incrementAmount)) / 100 * } * ] * * The two above series ignore the row values and simply key off of the index and summaryMetrics, but you could have * used the row values to, for instance, add two existing series to create a third. * * Notice how the entire seriesData is available inside of your provided callback. This would allow you to derive a metric * off of rows other than the current row like you would for a sliding-window calculation (Shewarts method). * * Just like all Lumenize Calculators, we can set holidays to be knocked out of the results. * * holidays = [ * {year: 2011, month: 1, day: 5} # Made up holiday to test knockout * ] * * Let&#39;s build the config Object from the above specifications and instantiate the calculator. * * config = * uniqueIDField: &quot;ObjectID&quot; * deriveFieldsOnInput: deriveFieldsOnInput * metrics: metrics * summaryMetricsConfig: summaryMetricsConfig * deriveFieldsAfterSummary: deriveFieldsAfterSummary * granularity: lumenize.Time.DAY * tz: &#39;America/Chicago&#39; * holidays: holidays * workDays: &#39;Sunday,Monday,Tuesday,Wednesday,Thursday,Friday&#39; # They work on Sundays * * calculator = new TimeSeriesCalculator(config) * * We can now send our snapshots into the calculator. * * startOnISOString = new Time(&#39;2011-01-02&#39;).getISOStringInTZ(config.tz) * upToDateISOString = new Time(&#39;2011-01-10&#39;).getISOStringInTZ(config.tz) * calculator.addSnapshots(snapshots, startOnISOString, upToDateISOString) * * Note, you must specify a startOnISOString and upToDateISOString. If you send in another round of snapshots, the new startOnISOString must match * the upToDateISOString of the prior call to addSnapshots(). This is the key to making sure that incremental calculations don&#39;t * skip or double count anything. You can even send in the same snapshots in a later round and they won&#39;t be double * counted. This idempotency property is also accomplished by the precise startOnISOString (current) upToDateISOString (prior) alignment. * If you restore the calculator from a saved state, the upToDate property will contain the prior upToDateISOString. You can use * this to compose a query that gets all of the snapshots necessary for the update. Just query with * `_ValidTo: {$gte: upToDate}`. Note, this will refetch all the snapshots that were still active the last time * you updated the calculator. This is expected and necessary. * * Let&#39;s print out our results and see what we have. * * keys = [&#39;label&#39;, &#39;StoryUnitScope&#39;, &#39;StoryCountScope&#39;, &#39;StoryCountBurnUp&#39;, * &#39;StoryUnitBurnUp&#39;, &#39;TaskUnitBurnDown&#39;, &#39;TaskUnitScope&#39;, &#39;Ideal&#39;, &#39;Ideal2&#39;, &#39;MedianPercentRemaining&#39;] * * csv = lumenize.arrayOfMaps_To_CSVStyleArray(calculator.getResults().seriesData, keys) * * console.log(csv.slice(1)) * # [ [ &#39;2011-01-02&#39;, 13, 3, 0, 0, 37, 32, 51, null, 100 ], * # [ &#39;2011-01-03&#39;, 18, 4, 0, 0, 44, 47, 42.5, 44, 100 ], * # [ &#39;2011-01-04&#39;, 20, 5, 1, 5, 25, 51, 34, 35.2, 41.666666666666664 ], * # [ &#39;2011-01-06&#39;, 20, 5, 2, 8, 16, 51, 25.5, 26.4, 41.666666666666664 ], * # [ &#39;2011-01-07&#39;, 18, 4, 3, 13, 3, 47, 17, 17.59, 0 ], * # [ &#39;2011-01-09&#39;, 18, 4, 3, 13, 3, 47, 8.5, 8.79, 0 ], * # [ &#39;2011-01-10&#39;, 18, 4, 3, 13, 3, 47, 0, 0, 0 ] ] * * ## Time-series group-by example ## * * allowedValues = [&#39;Ready to pull&#39;, &#39;In progress&#39;, &#39;In test&#39;, &#39;Accepted&#39;, &#39;Released&#39;] * * It supports both count and sum for group-by metrics * * metrics = [ * {f: &#39;groupBySum&#39;, field: &#39;PlanEstimate&#39;, groupByField: &#39;ScheduleState&#39;, allowedValues: allowedValues}, * {f: &#39;groupByCount&#39;, groupByField: &#39;ScheduleState&#39;, allowedValues: allowedValues, prefix: &#39;Count &#39;}, * {as: &#39;MedianTaskRemainingTotal&#39;, field: &#39;TaskRemainingTotal&#39;, f: &#39;median&#39;} # An example of how you might overlay a line series * ] * * holidays = [ * {year: 2011, month: 1, day: 5} # Made up holiday to test knockout * ] * * config = # default workDays * uniqueIDField: &quot;ObjectID&quot; * metrics: metrics * granularity: Time.DAY * tz: &#39;America/Chicago&#39; * holidays: holidays * workDays: &#39;Sunday,Monday,Tuesday,Wednesday,Thursday,Friday&#39; # They work on Sundays * * calculator = new TimeSeriesCalculator(config) * * startOnISOString = new Time(&#39;2010-12-31&#39;).getISOStringInTZ(config.tz) * upToDateISOString = new Time(&#39;2011-01-09&#39;).getISOStringInTZ(config.tz) * calculator.addSnapshots(snapshots, startOnISOString, upToDateISOString) * * Here is the output of the sum metrics * * keys = [&#39;label&#39;].concat(allowedValues) * csv = lumenize.arrayOfMaps_To_CSVStyleArray(calculator.getResults().seriesData, keys) * console.log(csv.slice(1)) * # [ [ &#39;2010-12-31&#39;, 5, 0, 0, 0, 0 ], * # [ &#39;2011-01-02&#39;, 8, 5, 0, 0, 0 ], * # [ &#39;2011-01-03&#39;, 10, 8, 0, 0, 0 ], * # [ &#39;2011-01-04&#39;, 7, 0, 8, 5, 0 ], * # [ &#39;2011-01-06&#39;, 2, 5, 5, 3, 5 ], * # [ &#39;2011-01-07&#39;, 0, 0, 5, 5, 8 ], * # [ &#39;2011-01-09&#39;, 0, 0, 5, 5, 8 ] ] * * Here is the output of the count metrics * * keys = [&#39;label&#39;].concat(&#39;Count &#39; + a for a in allowedValues) * csv = lumenize.arrayOfMaps_To_CSVStyleArray(calculator.getResults().seriesData, keys) * console.log(csv.slice(1)) * # [ [ &#39;2010-12-31&#39;, 1, 0, 0, 0, 0 ], * # [ &#39;2011-01-02&#39;, 2, 1, 0, 0, 0 ], * # [ &#39;2011-01-03&#39;, 2, 2, 0, 0, 0 ], * # [ &#39;2011-01-04&#39;, 2, 0, 2, 1, 0 ], * # [ &#39;2011-01-06&#39;, 1, 1, 1, 1, 1 ], * # [ &#39;2011-01-07&#39;, 0, 0, 1, 1, 2 ], * # [ &#39;2011-01-09&#39;, 0, 0, 1, 1, 2 ] ] * * We didn&#39;t output the MedianTaskRemainingTotal metric but it&#39;s in there. I included it to demonstrate that you can * calculate non-group-by series along side group-by series. * * The order of execution of all of the configurations that modify or augment the results is as follows: * * 1. **deriveFieldsOnInput** operate on the snapshots by adding virtual fields to them. This is done when addSnapshots() is * called before any time series calculations are run your metrics config can refer to them as if they were real fields * on the snapshots. * 1. **metrics** which defines the seriesData. * 1. **deriveFieldsOnOutput** operates on the output seriesData table that has one row per tick. It is also done when you call * addSnapshots() but after the seriesData calculations are completed. * 1. **summaryMetricsConfig** also operates on the seriesData table but rather than augmenting the rows in that table, it creates * a new table (summaryMetrics) that just contains summary information. An example usage would be to find the max scope to be * used by deriveFieldsAfterSummary to create an ideal line that burned down from that max. Note, this is run * every time you call getResults(), so it can potentially be expensive if you fetch the results often with getResults(). * 1. **deriveFieldsAfterSummary** is used next. It&#39;s essentially the same as deriveFieldsOnOutput and also creates new columns * in the seriesData table whose rows are made up of ticks. However, it allows you to use the summary table. Only use this * if the field calculation needs something from the summary (like an ideal line) because it is potentially more * expensive since it&#39;s done every time you call getResults(). * 1. **projectionsConfig** is the last augmentation config used. * * * @constructor * @param {Object} config * @cfg {String} tz The timezone for analysis * @cfg {String} [validFromField = &quot;_ValidFrom&quot;] * @cfg {String} [validToField = &quot;_ValidTo&quot;] * @cfg {String} [uniqueIDField = &quot;_EntityID&quot;] * @cfg {String} granularity &#39;month&#39;, &#39;week&#39;, &#39;quarter&#39;, &#39;day&#39;, etc. Use Time.MONTH, Time.WEEK, etc. * @cfg {String[]/String} [workDays = [&#39;Monday&#39;, &#39;Tuesday&#39;, &#39;Wednesday&#39;, &#39;Thursday&#39;, &#39;Friday&#39;]] List of days of the week that you work on. You can specify this as an Array of Strings * ([&#39;Monday&#39;, &#39;Tuesday&#39;, ...]) or a single comma seperated String (&quot;Monday,Tuesday,...&quot;). * @cfg {Object[]} [holidays] An optional Array containing rows that are either ISOStrings or JavaScript Objects * (mix and match). Example: `[{month: 12, day: 25}, {year: 2011, month: 11, day: 24}, &quot;2012-12-24&quot;]` * Notice how you can leave off the year if the holiday falls on the same day every year. * @cfg {Object} [workDayStartOn] An optional object in the form {hour: 8, minute: 15}. If minute is zero it can be omitted. * If workDayStartOn is later than workDayEndBefore, then it assumes that you work the night shift and your work * hours span midnight. If tickGranularity is &quot;hour&quot; or finer, you probably want to set this; if tickGranularity is * &quot;day&quot; or coarser, probably not. * @cfg {Object} [workDayEndBefore] An optional object in the form {hour: 17, minute: 0}. If minute is zero it can be omitted. * The use of workDayStartOn and workDayEndBefore only make sense when the granularity is &quot;hour&quot; or finer. * Note: If the business closes at 5:00pm, you&#39;ll want to leave workDayEndBefore to 17:00, rather * than 17:01. Think about it, you&#39;ll be open 4:59:59.999pm, but you&#39;ll be closed at 5:00pm. This also makes all of * the math work. 9am to 5pm means 17 - 9 = an 8 hour work day. * * @cfg {Object[]} [metrics=[]] Array which specifies the metrics to calculate for tick in time. * * Example: * * config = {} * config.metrics = [ * {field: &#39;field3&#39;}, # defaults to metrics: [&#39;sum&#39;] * {field: &#39;field4&#39;, metrics: [ * {f: &#39;sum&#39;}, # will add a metric named field4_sum * {as: &#39;median4&#39;, f: &#39;p50&#39;}, # renamed p50 to median4 from default of field4_p50 * {as: &#39;myCount&#39;, f: (values) -&gt; return values.length} # user-supplied function * ]} * ] * * If you specify a field without any metrics, it will assume you want the sum but it will not automatically * add the sum metric to fields with a metrics specification. User-supplied aggregation functions are also supported as * shown in the &#39;myCount&#39; metric above. * * Note, if the metric has dependencies (e.g. average depends upon count and sum) it will automatically add those to * your metric definition. If you&#39;ve already added a dependency but put it under a different &quot;as&quot;, it&#39;s not smart * enough to sense that and it will add it again. Either live with the duplication or leave * dependency metrics named their default by not providing an &quot;as&quot; field. * @cfg {Object[]} [deriveFieldsOnInput] An Array of Maps in the form `{field:&#39;myField&#39;, f:(fact)-&gt;...}` * @cfg {Object[]} [deriveFieldsOnOutput] same format at deriveFieldsOnInput, except the callback is in the form `f(row)` * This is only called for dirty rows that were effected by the latest round of additions in an incremental calculation. * @cfg {Object[]} [summaryMetricsConfig] Allows you to specify a list of metrics to calculate on the results before returning. * These can either be in the form of `{as: &#39;myMetric&#39;, field: &#39;field4&#39;, f:&#39;sum&#39;}` which would extract all of the values * for field `field4` and pass it as the values parameter to the `f` (`sum` in this example) function (from Lumenize.functions), or * it can be in the form of `{as: &#39;myMetric&#39;, f:(seriesData, summaryMetrics) -&gt; ...}`. Note, they are calculated * in order, so you can use the result of an earlier summaryMetric to calculate a later one. * @cfg {Object[]} [deriveFieldsAfterSummary] same format at deriveFieldsOnInput, except the callback is in the form `f(row, index, summaryMetrics, seriesData)` * This is called on all rows every time you call getResults() so it&#39;s less efficient than deriveFieldsOnOutput. Only use it if you need * the summaryMetrics in your calculation. * @cfg {Object} [projectionsConfig] Allows you to project series into the future * * Example: * * projectionsConfig = { * limit: 100 # optional, defaults to 300 * continueWhile: (point) -&gt; # Optional but recommended * return point.StoryCountScope_projection &gt; point.StoryCountBurnUp_projection * minFractionToConsider: 1.0 / 2.0 # optional, defaults to 1/3 * minCountToConsider: 3 # optional, defaults to 15 * series: [ * {as: &#39;ScopeProjection&#39;, field: &#39;StoryUnitScope&#39;, slope: 0.5}, * {field: &#39;StoryCountScope&#39;, slope: 0}, # 0 slope is a level projection * {field: &#39;StoryCountBurnUp&#39;}, # Will use v-Optimal (least squares of difference in angle / count) * {field: &#39;field5&#39;, startIndex: 0} # 0 will use entire series. Add grab-handle to allow user to specify some other index * ] * } * * When a projectionsConfig is provided, the TimeSeriesCalculator will add points to the output seriesData showing * the series being projected. These projected series will always start at the last point of the series and go out from there. * By default, they are named the same as the series (field) they are projecting with &#39;_projection&#39; concatenated onto the end. * However, this name can be overridden by using the `as` field of the series configuration. * * In addition to adding to the dataSeries, a summary of the projection is provided in the `projections` sub-field * returned when you call `getResults()`. The format of this sub-field is something like this: * * projections = { * &quot;limit&quot;: 100, * &quot;series&quot;: [ * {&quot;as&quot;: &quot;ScopeProjection&quot;, &quot;field&quot;: &quot;StoryUnitScope&quot;, &quot;slope&quot;: 0.5}, * {&quot;field&quot;: &quot;StoryCountScope&quot;, &quot;slope&quot;: 0}, * {&quot;field&quot;: &quot;StoryCountBurnUp&quot;, &quot;startIndex&quot;: 0, &quot;slope&quot;: 0.6}, * {&quot;field&quot;: &quot;field5&quot;, &quot;startIndex&quot;: 0, &quot;slope&quot;: 0.123259838293} * ], * &quot;minFractionToConsider&quot;: 0.5, * &quot;minCountToConsider&quot;: 3, * &quot;pointsAddedCount&quot;: 6, * &quot;lastPoint&quot;: { * &quot;tick&quot;: &quot;2011-01-17T06:00:00.000Z&quot;, * &quot;label&quot;: &quot;2011-01-16&quot;, * &quot;ScopeProjection&quot;: 21, * &quot;StoryCountScope_projection&quot;: 4, * &quot;StoryCountBurnUp_projection&quot;: 6.6 * } * } * * You can inspect this returned Object to see what slope it used for each series. Also, if you were not * rendering a chart but just wanted to use this calculator to make a holiday-knockout-precise forecast, you could * inspect the `lastPoint.tick` field to identify when this work is forecast to finish. * * One thing to keep in mind when using this functionality is that these calculators in general and these projections * in particular, is that the x-axis is a complex Timeline of ticks rather than simple linear calander time. * So, these projections will take into account any holidays specified in the future. * * The `projectionsConfig` is a fairly complicated configuration in its own right. It is embedded in the config object * for the overall TimeSeriesCalculator but it has a bunch of sub-configuration also. The five top level items are: * `limit`, `continueWhile`, `minFractionToConsider`, `minCountToConsider`, and `series`. * * `limit` and `continueWhile` * are used to control how far in the future the projection will go. It will stop at `limit` even if the `continueWhile` * is always met. This will prevent the projection from becoming an infinite loop. The `continueWhile` predicate * is technically not required but in almost all cases you will not know how far into the future you want to go * so you will have to use it. * * `minFractionToConsider` and `minCountToConsider` are used for series where you allow the calculator to find * the optimal starting point for the projection (the default behavior). It&#39;s very common for projects to start out slowly and then ramp up. * The optimal algorithm is designed to find this knee where the difference in angle of the projection is the minimum * of the square of the difference between the overall angle and all the sub-angles between this starting point going up to the point before * the last point. This minimum is also divided by the number of points so using more data points for the projection * is favored over using fewer. These two configuration parameters, `minFractionToConsider`, and `minCountToConsider` * tell the v-optimal algorthim the minimum number or portion of points to consider. This prevents the algorithm * from just using the angle of the last few points if they happen to be v-optimal. They currently default to the max of 1/3rd of the project or * 15 (3 work weeks if granularity is &#39;days&#39;). Note, that the `minCountToConsider` default is optimized for * granularity of &#39;days&#39;. If you were to use granularity of weeks, I would suggest a much lower number like 3 to 5. * If you were to use granularity of &#39;months&#39; then maybe 2-3 months would suffice. * * The `series` sub-config is similar to the main series config, with a required `field` field and an optional * `as` field. The remaining two possible fields (`startIndex` and `slope`) are both optional. They are also mutually * exclusive with the `slope` trumping the `startIndex` in cases where both are mistakenly provided. * If both are ommitted, then the projection will attempt to find the optimal starting point for the projection using the * algorithm described above. * * If the `slope` is specified, it will override any `startingIndex` specification. You will commonly set this * to 0 for scope series where you want the projection to only consider the current scope. If you set this manually, * be sure to remember that the &quot;run&quot; (slope = rise / run) is ticks along the x-axis (holidays and weekends knocked out), * not true calendar time. Also, note that in the output * (`getResults().projections.series`), the slope will always be set even if you did not specify one in your original * configuration. The startIndex or optimal (default) behaviors operate by setting this slope. * * The `startingIndex` is specified if you want to tell the projection from what point in time, the projection should * start. Maybe the project doubled staff 3 months into the project and you want the projection to start from there. * The common usage for this functionality is to provide a grab-handle on the chart and allow the user to use his * insight combined with the visualization of the data series to pick his own optimal starting point. Note, if you * specify a `startingIndex` you should not specify a `slope` and vice-versa. * * Note, that if you specify a `startIndex` or one is derived for you using the optimal algorithm, then the projection * series will reach back into the seriesData to this startIndex. If you are using HighCharts, you will want to set * connectNulls to true for projection series that have a startIndex. Projection series where you specify a `slope` * start at the end of the dataSeries and only project into the future. * * @cfg {String/ISOString/Date/Lumenize.Time} [startOn=-infinity] This becomes the master startOn for the entire calculator limiting * the calculator to only emit ticks equal to this or later. * @cfg {String/ISOString/Date/Lumenize.Time} [endBefore=infinity] This becomes the master endBefore for the entire calculator * limiting the calculator to only emit ticks before this. */ /* &lt;CoffeeScript&gt; limiting the calculator to only emit ticks before this. @config = utils.clone(config) @tickToLabelLookup = {} # Assert that the configuration object is self-consistent and required parameters are present unless @config.validFromField? @config.validFromField = &quot;_ValidFrom&quot; unless @config.validToField? @config.validToField = &quot;_ValidTo&quot; unless @config.uniqueIDField? @config.uniqueIDField = &quot;_EntityID&quot; utils.assert(@config.tz?, &quot;Must provide a timezone to this calculator.&quot;) utils.assert(@config.granularity?, &quot;Must provide a granularity to this calculator.&quot;) # translate groupByCount and groupBySum into deriveFieldsOnInput so: # {field: &#39;PlanEstimate&#39;, groupByField: &#39;ScheduleState&#39;, f: &#39;groupBySum&#39;, allowedValues: [&quot;a&quot;, &quot;b&quot;]} # # becomes in the deriveFieldsOnInput array: # {as: &quot;a&quot;, field: &#39;PlanEstimate&#39;, f: &#39;filteredSum&#39;, filterField: &#39;ScheduleState&#39;, filterValues: [&quot;a&quot;]} # {as: &quot;b&quot;, field: &#39;PlanEstimate&#39;, f: &#39;filteredSum&#39;, filterField: &#39;ScheduleState&#39;, filterValues: [&quot;b&quot;]} newMetrics = [] for a in @config.metrics if a.f in [&#39;groupBySum&#39;, &#39;groupByCount&#39;] unless a.prefix? a.prefix = &#39;&#39; for filterValue in a.allowedValues row = { as: a.prefix + filterValue, filterField: a.groupByField, filterValues: [filterValue] } if a.f == &#39;groupBySum&#39; row.field = a.field row.f = &#39;filteredSum&#39; else row.f = &#39;filteredCount&#39; newMetrics.push(row) else newMetrics.push(a) @config.metrics = newMetrics filteredCountCreator = (filterField, filterValues) -&gt; # !TODO: Change this and the one below to strings with eval so they can be serialized and deserialized f = (row) -&gt; if row[filterField] in filterValues then return 1 else return 0 return f filteredSumCreator = (field, filterField, filterValues) -&gt; f = (row) -&gt; if row[filterField] in filterValues then return row[field] else return 0 return f # add to deriveFieldsOnInput for filteredCount and filteredSum for a in @config.metrics if a.f in [&#39;filteredCount&#39;, &#39;filteredSum&#39;] if a.f == &#39;filteredCount&#39; f = filteredCountCreator(a.filterField, a.filterValues) else f = filteredSumCreator(a.field, a.filterField, a.filterValues) unless a.as? throw new Error(&quot;Must provide `as` specification for a `#{a.f}` metric.&quot;) unless @config.deriveFieldsOnInput? @config.deriveFieldsOnInput = [] @config.deriveFieldsOnInput.push({as: a.as, f: f}) a.f = &#39;sum&#39; a.field = a.as inputCubeDimensions = [ {field: @config.uniqueIDField}, {field: &#39;tick&#39;} ] fieldsMap = {} for m in @config.metrics if m.field? fieldsMap[m.field] = true inputCubeMetrics = [] for field of fieldsMap inputCubeMetrics.push({field, f: &#39;firstValue&#39;, as: field}) @inputCubeConfig = # We just hold into this and use it when new snapshots are added dimensions: inputCubeDimensions metrics: inputCubeMetrics deriveFieldsOnInput: @config.deriveFieldsOnInput dimensions = [{field: &#39;tick&#39;}] @cubeConfig = dimensions: dimensions metrics: @config.metrics deriveFieldsOnOutput: @config.deriveFieldsOnOutput @toDateCubeConfig = utils.clone(@cubeConfig) @toDateCubeConfig.deriveFieldsOnInput = @config.deriveFieldsOnInput @cube = new OLAPCube(@cubeConfig) @upToDateISOString = null if @config.summaryMetricsConfig? for m in @config.summaryMetricsConfig functions.expandFandAs(m) if config.startOn? @masterStartOnTime = new Time(config.startOn, @config.granularity, @config.tz) else @masterStartOnTime = new Time(&#39;BEFORE_FIRST&#39;, @config.granularity) if config.endBefore? @masterEndBeforeTime = new Time(config.endBefore, @config.granularity, @config.tz) else @masterEndBeforeTime = new Time(&#39;PAST_LAST&#39;, @config.granularity) if config.startOn? and config.endBefore? timelineConfig = utils.clone(@config) timelineConfig.startOn = @masterStartOnTime timelineConfig.endBefore = @masterEndBeforeTime timeline = new Timeline(timelineConfig) ticks = timeline.getAll(&#39;Timeline&#39;, @config.tz, @config.granularity) @tickToLabelLookup[tl.endBefore.getISOStringInTZ(config.tz)] = tl.startOn.toString() for tl in ticks addSnapshots: (snapshots, startOnISOString, upToDateISOString) -&gt; &lt;/CoffeeScript&gt; */ <span id='Lumenize-TimeSeriesCalculator-method-addSnapshots'> /** </span> * @method addSnapshots * @member Lumenize.TimeSeriesCalculator * Allows you to incrementally add snapshots to this calculator. * @chainable * @param {Object[]} snapshots An array of temporal data model snapshots. * @param {String} startOnISOString A ISOString (e.g. &#39;2012-01-01T12:34:56.789Z&#39;) indicating the time start of the period of * interest. On the second through nth call, this should equal the previous upToDateISOString. * @param {String} upToDateISOString A ISOString (e.g. &#39;2012-01-01T12:34:56.789Z&#39;) indicating the moment just past the time * period of interest. * @return {TimeInStateCalculator} */ /* &lt;CoffeeScript&gt; if @upToDateISOString? utils.assert(@upToDateISOString == startOnISOString, &quot;startOnISOString (#{startOnISOString}) parameter should equal upToDateISOString of previous call (#{@upToDateISOString}) to addSnapshots.&quot;) @upToDateISOString = upToDateISOString advanceOneTimelineConfig = utils.clone(@config) advanceOneTimelineConfig.startOn = new Time(upToDateISOString, @config.granularity, @config.tz) delete advanceOneTimelineConfig.endBefore advanceOneTimelineConfig.limit = 2 advanceOneTimeline = new Timeline(advanceOneTimelineConfig) advanceOneTimelineIterator = advanceOneTimeline.getIterator() advanceOneTimelineIterator.next() endBeforeTime = advanceOneTimelineIterator.next() timelineConfig = utils.clone(@config) startOnTime = new Time(startOnISOString, @config.granularity, @config.tz) if startOnTime.greaterThan(@masterStartOnTime) timelineConfig.startOn = startOnTime else timelineConfig.startOn = @masterStartOnTime if endBeforeTime.lessThan(@masterEndBeforeTime) timelineConfig.endBefore = endBeforeTime else timelineConfig.endBefore = @masterEndBeforeTime @asOfISOString = timelineConfig.endBefore.getISOStringInTZ(@config.tz) timeline = new Timeline(timelineConfig) ticks = timeline.getAll(&#39;Timeline&#39;, @config.tz, @config.granularity) @tickToLabelLookup[tl.endBefore.getISOStringInTZ(@config.tz)] = tl.startOn.toString() for tl in ticks validSnapshots = [] for s in snapshots ticks = timeline.ticksThatIntersect(s[@config.validFromField], s[@config.validToField], @config.tz) if ticks.length &gt; 0 s.tick = ticks validSnapshots.push(s) inputCube = new OLAPCube(@inputCubeConfig, validSnapshots) @cube.addFacts(inputCube.getCells()) if true or @masterEndBeforeTime.greaterThanOrEqual(endBeforeTime) @toDateSnapshots = [] for s in snapshots if s[@config.validToField] &gt; @asOfISOString &gt;= s[@config.validFromField] @toDateSnapshots.push(s) else @toDateSnapshots = undefined return this getResults: () -&gt; &lt;/CoffeeScript&gt; */ <span id='Lumenize-TimeSeriesCalculator-method-getResults'> /** </span> * @method getResults * @member Lumenize.TimeSeriesCalculator * Returns the current state of the calculator * @return {Object[]} Returns an Array of Maps like `{&lt;uniqueIDField&gt;: &lt;id&gt;, ticks: &lt;ticks&gt;, lastValidTo: &lt;lastValidTo&gt;}` */ /* &lt;CoffeeScript&gt; ticks = utils.keys(@tickToLabelLookup).sort() labels = (@tickToLabelLookup[t] for t in ticks) # Calculate metrics for @toDateSnapshots if @toDateSnapshots? and @toDateSnapshots.length &gt; 0 for s in @toDateSnapshots s.tick = &#39;To Date&#39; toDateCube = new OLAPCube(@toDateCubeConfig, @toDateSnapshots) toDateCell = toDateCube.getCells()[0] delete toDateCell._count # Add toDateCell and put labels on cells seriesData = [] foundFirstNullCell = false for t, tickIndex in ticks cell = utils.clone(@cube.getCell({tick: t})) if cell? delete cell._count else startOn = new Time(labels[tickIndex]).getISOStringInTZ(@config.tz) if toDateCell and startOn &lt; @asOfISOString &lt;= t # Then it&#39;s the to-date value cell = toDateCell else # it&#39;s blank and should be filled in with nulls cell = {} for m in @config.metrics cell[m.as] = null cell.tick = t cell.label = @tickToLabelLookup[cell.tick] seriesData.push(cell) # derive summary metrics summaryMetrics = {} if @config.summaryMetricsConfig? for summaryMetric in @config.summaryMetricsConfig if summaryMetric.field? # get all values of that field values = [] for row in seriesData values.push(row[summaryMetric.field]) summaryMetrics[summaryMetric.as] = summaryMetric.f(values) else summaryMetrics[summaryMetric.as] = summaryMetric.f(seriesData, summaryMetrics) # deriveFieldsAfterSummaryMetrics - This is more expensive than deriveFieldsOnOutput so only use it if you must. if @config.deriveFieldsAfterSummary? for row, index in seriesData for d in @config.deriveFieldsAfterSummary row[d.as] = d.f(row, index, summaryMetrics, seriesData) # derive projections projections = {} if @config.projectionsConfig? projections = utils.clone(@config.projectionsConfig) # add to last point in seriesData lastIndex = seriesData.length - 1 lastPoint = seriesData[lastIndex] lastTick = lastPoint.tick # !TODO: May need to do something different if there is an upToDateCell for projectionSeries in projections.series as = projectionSeries.as || projectionSeries.field + &quot;_projection&quot; lastPoint[as] = lastPoint[projectionSeries.field] # set slope if missing for projectionSeries in projections.series unless projectionSeries.slope? unless projectionSeries.startIndex? unless projections.minFractionToConsider? projections.minFractionToConsider = 1.0 / 3.0 unless projections.minCountToConsider? projections.minCountToConsider = 15 highestIndexAllowed1 = Math.floor((1 - projections.minFractionToConsider) * seriesData.length) - 1 highestIndexAllowed2 = seriesData.length - 1 - projections.minCountToConsider highestIndexAllowed = Math.min(highestIndexAllowed1, highestIndexAllowed2) if highestIndexAllowed &lt; 1 projectionSeries.startIndex = 0 else projectionSeries.startIndex = TimeSeriesCalculator._findVOptimalProjectionStartIndex(seriesData, projectionSeries.field, highestIndexAllowed) startIndex = projectionSeries.startIndex startPoint = seriesData[startIndex] # Add first point in projection series for series where a startIndex is specified as = projectionSeries.as || projectionSeries.field + &quot;_projection&quot; startPoint[as] = startPoint[projectionSeries.field] # calculate slope projectionSeries.slope = (lastPoint[projectionSeries.field] - startPoint[projectionSeries.field]) / (lastIndex - startIndex) # get projectionTimelineIterator projectionTimelineConfig = utils.clone(@config) projectionTimelineConfig.startOn = new Time(lastTick, @config.granularity, @config.tz) delete projectionTimelineConfig.endBefore projectionTimelineConfig.limit = projections.limit || 300 projectionTimeline = new Timeline(projectionTimelineConfig) projectionTimelineIterator = projectionTimeline.getIterator(&#39;Timeline&#39;) pointsAddedCount = 0 projectedPoint = null while projectionTimelineIterator.hasNext() and (not projectedPoint? or (not projections.continueWhile? or projections.continueWhile(projectedPoint))) pointsAddedCount++ projectedPoint = {} tick = projectionTimelineIterator.next() projectedPoint.tick = tick.endBefore.getISOStringInTZ(@config.tz) projectedPoint.label = tick.startOn.toString() for projectionSeries in projections.series as = projectionSeries.as || projectionSeries.field + &quot;_projection&quot; projectedPoint[as] = lastPoint[projectionSeries.field] + pointsAddedCount * projectionSeries.slope seriesData.push(projectedPoint) projections.pointsAddedCount = pointsAddedCount projections.lastPoint = projectedPoint return {seriesData, summaryMetrics, projections} @_findVOptimalProjectionStartIndex: (seriesData, field, highestIndexAllowed) -&gt; utils.assert(highestIndexAllowed &lt; seriesData.length - 2, &quot;Cannot use the last two points for calculating v-optimal slope.&quot;) lastIndex = seriesData.length - 1 lastPoint = seriesData[lastIndex] slopeToEnd = (index) =&gt; return (lastPoint[field] - seriesData[index][field]) / (lastIndex - index) calculateTotalErrorSquared = (index) =&gt; trialSlope = slopeToEnd(index) trialAngle = Math.atan(trialSlope) totalErrorSquared = 0 for i in [(index + 1)..(lastIndex - 1)] currentSlope = slopeToEnd(i) currentAngle = Math.atan(currentSlope) # error = trialSlope - currentSlope error = trialAngle - currentAngle totalErrorSquared += error * error return totalErrorSquared minNormalizedErrorSquared = Number.MAX_VALUE indexForMinNormalizedErrorSquared = highestIndexAllowed for i in [highestIndexAllowed..0] errorSquared = calculateTotalErrorSquared(i) normalizedErrorSquared = errorSquared / (seriesData.length - 2 - i) if normalizedErrorSquared &lt;= minNormalizedErrorSquared minNormalizedErrorSquared = normalizedErrorSquared indexForMinNormalizedErrorSquared = i return indexForMinNormalizedErrorSquared getStateForSaving: (meta) -&gt; &lt;/CoffeeScript&gt; */ <span id='Lumenize-TimeSeriesCalculator-method-getStateForSaving'> /** </span> * @method getStateForSaving * @member Lumenize.TimeSeriesCalculator * Enables saving the state of this calculator. See class documentation for a detailed example. * @param {Object} [meta] An optional parameter that will be added to the serialized output and added to the meta field * within the deserialized calculator. * @return {Object} Returns an Ojbect representing the state of the calculator. This Object is suitable for saving to * to an object store. Use the static method `newFromSavedState()` with this Object as the parameter to reconstitute * the calculator. */ /* &lt;CoffeeScript&gt; out = config: @config cubeSavedState: @cube.getStateForSaving() upToDateISOString: @upToDateISOString if meta? out.meta = meta return out @newFromSavedState: (p) -&gt; &lt;/CoffeeScript&gt; */ <span id='Lumenize-TimeSeriesCalculator-static-method-newFromSavedState'> /** </span> * @method newFromSavedState * @member Lumenize.TimeSeriesCalculator * Deserializes a previously saved calculator and returns a new calculator. See class documentation for a detailed example. * @static * @param {String/Object} p A String or Object from a previously saved state * @return {TimeInStateCalculator} */ /* &lt;CoffeeScript&gt; if utils.type(p) is &#39;string&#39; p = JSON.parse(p) calculator = new TimeSeriesCalculator(p.config) calculator.cube = OLAPCube.newFromSavedState(p.cubeSavedState) calculator.upToDateISOString = p.upToDateISOString if p.meta? calculator.meta = p.meta return calculator exports.TimeSeriesCalcula