add table reducer

This commit is contained in:
ryan
2019-03-12 01:29:01 -07:00
parent 304912f9b4
commit b7963f8e87
2 changed files with 255 additions and 0 deletions
@@ -0,0 +1,18 @@
import { parseCSV } from './processTableData';
import { reduceTableData } from './tableReducer';
describe('Table Reducer', () => {
it('should calculate average', () => {
const table = parseCSV('a,b,c\n1,2,3\n4,5,6');
const reduced = reduceTableData(table, {
stats: ['last'],
});
expect(reduced.length).toBe(1);
expect(reduced[0].rows.length).toBe(1);
expect(reduced[0].rows[0]).toEqual(table.rows[1]);
console.log('REDUCE', reduced[0].rows);
});
});
@@ -0,0 +1,237 @@
// Libraries
import isNumber from 'lodash/isNumber';
import { TableData, NullValueMode } from '../types/index';
/** Reduce each column in a table to a single value */
export type TableReducer = (
data: TableData,
columnIndexes: number[],
ignoreNulls: boolean,
nullAsZero: boolean
) => any[];
/** Information about the reducing(stats) functions */
export interface TableReducerInfo {
key: string;
name: string;
description: string;
standard: boolean; // The most common stats can all be calculated in a single pass
reducer?: TableReducer;
alias?: string; // optional secondary key. 'avg' vs 'mean'
}
/** Get a list of the known reducing functions */
export function getTableReducers(): TableReducerInfo[] {
return reducers;
}
export interface TableReducerOptions {
columnIndexes?: number[];
nullValueMode?: NullValueMode;
stats: string[]; // The stats to calculate
}
export function reduceTableData(data: TableData, options: TableReducerOptions): TableData[] {
if (registry == null) {
registry = new Map<string, TableReducerInfo>();
reducers.forEach(calc => {
registry!.set(calc.key, calc);
if (calc.alias) {
registry!.set(calc.alias, calc);
}
});
}
const indexes = verifyColumns(data, options);
const columns = indexes.map(v => data.columns[v]);
const ignoreNulls = options.nullValueMode === NullValueMode.Ignore;
const nullAsZero = options.nullValueMode === NullValueMode.AsZero;
const queue = options.stats.map(key => {
const c = registry!.get(key);
if (!c) {
throw new Error('Unknown stats calculator: ' + key);
}
return c;
});
// Avoid the standard calculator if possible
if (queue.length === 1 && queue[0].reducer) {
return [
{
columns,
rows: [queue[0].reducer(data, indexes, ignoreNulls, nullAsZero)],
type: 'table',
columnMap: {},
},
];
}
// For now everything can use the standard stats
const standard = standardStatsReducer(data, indexes, ignoreNulls, nullAsZero);
return queue.map(calc => {
const values = calc.standard
? standard.map((s: any) => s[calc.key])
: calc.reducer!(data, indexes, ignoreNulls, nullAsZero);
return {
columns,
rows: [values],
type: 'table',
columnMap: {},
};
});
}
// ------------------------------------------------------------------------------
//
// No Exported symbols below here.
//
// ------------------------------------------------------------------------------
/**
* This will return an array of valid indexes and throw an error if invalid request
*/
function verifyColumns(data: TableData, options: TableReducerOptions): number[] {
const { columnIndexes } = options;
if (!columnIndexes) {
return data.columns.map((v, idx) => idx);
}
columnIndexes.forEach(v => {
if (v < 0 || v >= data.columns.length) {
throw new Error('Invalid column selection: ' + v);
}
});
return columnIndexes;
}
interface StandardStats {
sum: number | null; // total
max: number | null;
min: number | null;
logmin: number;
mean: number | null; // avg
last: any; // current
first: any;
count: number;
nonNullCount: number;
range: number | null;
diff: number | null;
allIsZero: boolean;
allIsNull: boolean;
}
const reducers: TableReducerInfo[] = [
{ key: 'sum', alias: 'total', name: 'Total', description: 'The sum of all values', standard: true },
{ key: 'min', name: 'Min', description: 'Minimum Value', standard: true },
{ key: 'max', name: 'Max', description: 'Maximum Value', standard: true },
{ key: 'mean', name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
{ key: 'first', name: 'First', description: 'First Value', standard: true },
{ key: 'last', name: 'Last', description: 'Last Value (current)', standard: true, alias: 'current' },
{ key: 'count', name: 'Count', description: 'Value Count', standard: true },
{ key: 'range', name: 'Range', description: 'Difference between minimum and maximum values', standard: true },
{ key: 'diff', name: 'Difference', description: 'Difference between first and last values', standard: true },
];
let registry: Map<string, TableReducerInfo> | null = null;
function standardStatsReducer(
data: TableData,
columnIndexes: number[],
ignoreNulls: boolean,
nullAsZero: boolean
): StandardStats[] {
const column = columnIndexes.map(idx => {
return {
sum: 0,
max: -Number.MAX_VALUE,
min: Number.MAX_VALUE,
logmin: Number.MAX_VALUE,
mean: null,
last: null,
first: null,
count: 0,
nonNullCount: 0,
allIsNull: true,
allIsZero: false,
range: null,
diff: null,
} as StandardStats;
});
for (let i = 0; i < data.rows.length; i++) {
for (let x = 0; x < column.length; x++) {
const stats = column[x];
let currentValue = data.rows[i][x];
if (currentValue === null) {
if (ignoreNulls) {
continue;
}
if (nullAsZero) {
currentValue = 0;
}
}
if (stats.first === null) {
stats.first = currentValue;
}
if (currentValue !== null) {
stats.last = currentValue;
if (isNumber(currentValue)) {
stats.sum! += currentValue;
stats.allIsNull = false;
stats.nonNullCount++;
}
if (currentValue > stats.max!) {
stats.max = currentValue;
}
if (currentValue < stats.min!) {
stats.min = currentValue;
}
if (currentValue < stats.logmin && currentValue > 0) {
stats.logmin = currentValue;
}
if (currentValue !== 0) {
stats.allIsZero = false;
}
}
}
}
for (let x = 0; x < column.length; x++) {
const stats = column[x];
if (stats.max === -Number.MAX_VALUE) {
stats.max = null;
}
if (stats.min === Number.MAX_VALUE) {
stats.min = null;
}
if (stats.nonNullCount > 0) {
stats.mean = stats.sum! / stats.nonNullCount;
}
if (stats.max !== null && stats.min !== null) {
stats.range = stats.max - stats.min;
}
if (stats.first !== null && stats.last !== null) {
if (isNumber(stats.first) && isNumber(stats.last)) {
stats.diff = stats.last - stats.first;
}
}
}
return column;
}