diff --git a/src/utility/lib/continuousDistribution.js b/src/utility/lib/continuousDistribution.js new file mode 100644 index 00000000..9c80d19d --- /dev/null +++ b/src/utility/lib/continuousDistribution.js @@ -0,0 +1,204 @@ +const _ = require('lodash'); +const { interpolate, range, min, max } = require('./functions'); + +class ContinuousDistribution { + /** + * @param {number[]} xs + * @param {number[]} ys + */ + constructor(xs, ys) { + if (!_.isArray(xs)) { + throw new Error('XS should be an array.'); + } + if (!_.isArray(ys)) { + throw new Error('YS should be an array.'); + } + if (!this.validateHasLength(xs)) { + throw new Error('You need at least one element.'); + } + if (!this.validateSize(xs, ys)) { + throw new Error('Arrays of "xs" and "ys" have different sizes.'); + } + + const sorted = this.order(xs, ys); + this.xs = sorted.xs; + this.ys = sorted.ys; + } + + /** + * Order them to make sure that xs are increasing + * @param {number[]} xs + * @param {number[]} ys + * @return {{ys: number[], xs: number[]}} + */ + order(xs, ys) { + const xsYs = xs.map((v, i) => ({ ys: ys[i], xs: v })); + const sorted = xsYs.sort((a, b) => { + if (a.xs > b.xs) return 1; + if (a.xs < b.xs) return -1; + return 0; + }); + + const XS = sorted.map(v => v.xs); + const YS = sorted.map(v => v.ys); + + return { xs: XS, ys: YS }; + } + + /** + * @param {number[]} xs + * @param {number[]} ys + * @return {boolean} + */ + validateSize(xs, ys) { + return xs.length === ys.length; + } + + /** + * @param xs + * @returns {boolean} + */ + validateHasLength(xs) { + return xs.length > 0; + } + + /** + * @returns {number} + */ + minX() { + return this.xs[0] + } + + /** + * @returns {number} + */ + maxX() { + return this.xs[this.xs.length - 1] + } + + /** + * If xs=[1,2,3], and ys=[5,6,7], + * then findY(1) = 5, findY(3) = 7, findY(1.5) = 5.5 + * @param {number} x + * @return {number} + */ + findY(x) { + let firstHigherIndex = this.xs.findIndex(X => X >= x); + if (firstHigherIndex < 0) return this.ys[this.ys.length - 1]; + if (firstHigherIndex === 0) return this.ys[0]; + let lowerOrEqualIndex = firstHigherIndex - 1; + if (lowerOrEqualIndex < 0) lowerOrEqualIndex = 0; + let needsInterpolation = this.xs[lowerOrEqualIndex] !== x; + if (needsInterpolation) { + return interpolate( + this.xs[lowerOrEqualIndex], + this.xs[firstHigherIndex], + this.ys[lowerOrEqualIndex], + this.ys[firstHigherIndex], + x + ); + } else { + return this.ys[lowerOrEqualIndex]; + } + } + + /** + * If xs=[1,2,3], and ys=[5,6,7], + * then findX(5) = 1, findX(7) = 3, findY(5.5) = 1.5 + * This should do the same thing as `findY`, but for Y. + * @param {number} y + * @return {number} + */ + findX(y) { + let firstHigherIndex = this.ys.findIndex(Y => Y >= y); + if (firstHigherIndex < 0) return this.xs[this.xs.length - 1]; + if (firstHigherIndex === 0) return this.xs[0]; + let lowerOrEqualIndex = firstHigherIndex - 1; + if (lowerOrEqualIndex < 0) lowerOrEqualIndex = 0; + let needsInterpolation = this.ys[lowerOrEqualIndex] !== y; + if (needsInterpolation) { + return interpolate( + this.ys[lowerOrEqualIndex], + this.ys[firstHigherIndex], + this.xs[lowerOrEqualIndex], + this.xs[firstHigherIndex], + y + ); + } else { + return this.xs[lowerOrEqualIndex]; + } + } + + /** + * @param {number[]} xs + * @return {ContinuousDistribution} + */ + convertWithAlternativeXs(xs) { + const ys = xs.map(x => this.findY(x)); + return new ContinuousDistribution(xs, ys); + } + + /** + * @param {number} newLength + * @return {ContinuousDistribution} + */ + convertToNewLength(newLength) { + const _range = range(min(this.xs), max(this.xs), newLength); + return this.convertWithAlternativeXs(_range); + } + + /** + * @return {number} + */ + sampleSingle() { + const y = Math.random(); + return this.findX(y); + } + + /** + * Poduce n samples, using ``sampleSingle`` for each. + * @param size + * @return {number[]} + */ + sample(size) { + return Array.from(Array(size), () => this.sampleSingle()); + } + + /** + * Finds the integral. Takes the average Y value between points, + * treating them like a triangle. + * @return {number[]} + */ + integral(params = { filterOutNaNs: false }) { + let integral = 0; + if (!params.filterOutNaNs && _.includes(this.ys, NaN)) { + return NaN; + } else if (_.includes(this.ys, Infinity) && _.includes(this.ys, -Infinity)) { + return NaN; + } else if (_.includes(this.ys, Infinity)) { + return Infinity; + } else if (_.includes(this.ys, -Infinity)) { + return -Infinity; + } + for (let i = 1; i < this.ys.length; i++) { + let thisY = this.ys[i]; + let lastY = this.ys[i - 1]; + let thisX = this.xs[i]; + let lastX = this.xs[i - 1]; + + if ( + _.isFinite(thisY) && _.isFinite(lastY) && + _.isFinite(thisX) && _.isFinite(lastX) + ) { + let sectionInterval = ((thisY + lastY) / 2) * (thisX - lastX); + integral = integral + sectionInterval; + } + + } + return integral; + } +} + +module.exports = { + ContinuousDistribution, +}; diff --git a/src/utility/lib/continuousDistribution.spec.js b/src/utility/lib/continuousDistribution.spec.js new file mode 100644 index 00000000..75831fb5 --- /dev/null +++ b/src/utility/lib/continuousDistribution.spec.js @@ -0,0 +1,111 @@ +const { ContinuousDistribution } = require('./continuousDistribution'); +const { up, down } = require('./functions'); + +describe('ContinuousDistribution Class', () => { + it('constructor()', () => { + const xs = up(1, 9); + const ys = up(1, 8); + expect(() => { + new ContinuousDistribution(xs, ys); + }).toThrow(/^Arrays of "xs" and "ys" have different sizes.$/); + }); + it('order()', () => { + const xs = down(9, 1); + const ys = down(9, 1); + const cdf = new ContinuousDistribution(xs, ys); + expect(cdf.xs).toEqual(up(1, 9)); + expect(cdf.ys).toEqual(up(1, 9)); + }); + it('findY()', () => { + const xs = [1, 2, 3]; + const ys = [5, 6, 7]; + const cdf = new ContinuousDistribution(xs, ys); + expect(cdf.findY(1)).toEqual(5); + expect(cdf.findY(1.5)).toEqual(5.5); + expect(cdf.findY(3)).toEqual(7); + expect(cdf.findY(4)).toEqual(7); + expect(cdf.findY(15)).toEqual(7); + expect(cdf.findY(-1)).toEqual(5); + }); + it('findX()', () => { + const xs = [1, 2, 3]; + const ys = [5, 6, 7]; + const cdf = new ContinuousDistribution(xs, ys); + expect(cdf.findX(5)).toEqual(1); + expect(cdf.findX(7)).toEqual(3); + expect(cdf.findX(5.5)).toEqual(1.5); + expect(cdf.findX(8)).toEqual(3); + expect(cdf.findX(4)).toEqual(1); + }); + it('convertWithAlternativeXs() when "XS" within "xs"', () => { + const xs = up(1, 9); + const ys = up(20, 28); + const cdf = new ContinuousDistribution(xs, ys); + const XS = up(3, 7); + const CDF = cdf.convertWithAlternativeXs(XS); + expect(CDF.xs).toEqual([3, 4, 5, 6, 7]); + expect(CDF.ys).toEqual([22, 23, 24, 25, 26]); + }); + it('convertToNewLength()', () => { + const xs = up(1, 9); + const ys = up(50, 58); + const cdf = new ContinuousDistribution(xs, ys); + const CDF = cdf.convertToNewLength(3); + expect(CDF.xs).toEqual([1, 5, 9]); + expect(CDF.ys).toEqual([50, 54, 58]); + }); + it('sample()', () => { + const xs = up(1, 9); + const ys = up(70, 78); + const cdf = new ContinuousDistribution(xs, ys); + const XS = cdf.sample(3); + expect(Number.isInteger(XS[0])).toBe(true); + expect(Number.isInteger(XS[1])).toBe(true); + expect(Number.isInteger(XS[2])).toBe(true); + }); + + describe('integral()', () => { + it('with regular inputs', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, 2.0, 2.0]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral(); + expect(integral).toEqual(6); + }); + it('with an infinity', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, Infinity, 2.0]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral(); + expect(integral).toEqual(Infinity); + }); + it('with negative infinity', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, -Infinity, 2.0]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral(); + expect(integral).toEqual(-Infinity); + }); + it('with both positive and negative infinities', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, -Infinity, Infinity]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral(); + expect(integral).toEqual(NaN); + }); + it('with a NaN and filterOutNaNs set to false', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, 2.0, NaN]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral({filterOutNaNs: false}); + expect(integral).toEqual(NaN); + }); + it('with a NaN and filterOutNaNs set to true', () => { + const xs = [0,1,2,4]; + const ys = [0.0, 1.0, 2.0, NaN]; + const cdf = new ContinuousDistribution(xs, ys); + const integral = cdf.integral({filterOutNaNs: true}); + expect(integral).toEqual(2); + }); + }) +}); diff --git a/src/utility/lib/functions.js b/src/utility/lib/functions.js new file mode 100644 index 00000000..0943e530 --- /dev/null +++ b/src/utility/lib/functions.js @@ -0,0 +1,136 @@ +/** + * @param {number} xMin + * @param {number} xMax + * @param {number} yMin + * @param {number} yMax + * @param {number} xIntended + * @return {number} + */ +function interpolate(xMin, xMax, yMin, yMax, xIntended) { + const minProportion = (xMax - xIntended) / (xMax - xMin); + const maxProportion = (xIntended - xMin) / (xMax - xMin); + return (yMin * minProportion) + (yMax * maxProportion); +} + +/** + * This should return an array of n evenly-spaced items + * between min and max, including min and max. + * range(1,5,3) = [1, 3, 5]; + * range(1,5,5) = [1, 2, 3, 4, 5]; + * @param {number} min + * @param {number} max + * @param {number} n + * @return {number[]} + */ +function range(min, max, n) { + if (n <= 0) throw new RangeError('n is less then zero'); + if (n === Infinity) throw new RangeError('n is Infinity'); + if (n === 0) return []; + if (n === 1) return [min]; + if (n === 2) return [min, max]; + if (min === max) return Array(n).fill(min); + n -= 1; + const diff = min - max; + const interval = Math.abs(diff / n); + + const result = []; + + let item = min; + do { + result.push(item); + item += interval; + } while (item <= max); + + // corrects results because of math errors + if ((n + 1) - result.length === 1) { + result.push(max); + } + + return result; +} + +/** + * @param {number[]} arr + * @return {number} + */ +function sum(arr) { + return arr.reduce((acc, val) => acc + val, 0); +} + +/** + * @param {number[]} arr + * @return {number} + */ +function mean(arr) { + return sum(arr) / arr.length; +} + +/** + * @param {number[]} arr + * @return {number} + */ +function min(arr) { + let val = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i] < val) { + val = arr[i]; + } + } + return val; +} + +/** + * @param {number[]} arr + * @return {number} + */ +function max(arr) { + let val = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i] > val) { + val = arr[i]; + } + } + return val; +} + +/** + * @param {number} min + * @param {number} max + * @return {number} + */ +function random(min, max) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +/** + * @param {number} from + * @param {number} to + * @return {number[]} + */ +function up(from, to) { + const arr = []; + for (let i = from; i <= to; i++) arr.push(i); + return arr; +} + +/** + * @param {number} from + * @param {number} to + * @return {number[]} + */ +function down(from, to) { + const arr = []; + for (let i = from; i >= to; i--) arr.push(i); + return arr; +} + +module.exports = { + interpolate, + min, + max, + range, + mean, + random, + up, + down, +}; \ No newline at end of file diff --git a/src/utility/lib/functions.spec.js b/src/utility/lib/functions.spec.js new file mode 100644 index 00000000..948f1f20 --- /dev/null +++ b/src/utility/lib/functions.spec.js @@ -0,0 +1,55 @@ +const { interpolate } = require('./functions'); +const { range } = require('./functions'); +const { mean } = require('./functions'); +const { min } = require('./functions'); +const { max } = require('./functions'); +const { random } = require('./functions'); +const { up, down } = require('./functions'); + +describe('Functions', () => { + it('interpolate()', () => { + expect(interpolate( + 10, 20, + 1, 2, + 15 + )).toBe(1.5); + }); + it('range()', () => { + expect(range(1, 5, 3)).toEqual([1, 3, 5]); + expect(range(1, 5, 5)).toEqual([1, 2, 3, 4, 5]); + expect(range(-10, 15, 2)).toEqual([-10, 15]); + expect(range(-10, 15, 3)).toEqual([-10, 2.5, 15]); + expect(range(-10.3, 17, 3)).toEqual([-10.3, 3.3499999999999996, 17]); + expect(range(-10.3, 17, 5)).toEqual([-10.3, -3.4750000000000005, 3.3499999999999996, 10.175, 17]); + expect(range(-10.3, 17.31, 3)).toEqual([-10.3, 3.504999999999999, 17.31]); + expect(range(1, 1, 3)).toEqual([1, 1, 1]); + }); + it('mean()', () => { + expect(mean([1, 2, 3])).toBe(2); + expect(mean([1, 2, 3, -2])).toBe(1); + expect(mean([1, 2, 3, -2, -10])).toBe(-1.2); + }); + it('min()', () => { + expect(min([1, 2, 3])).toBe(1); + expect(min([-1, -2, 0, 20])).toBe(-2); + expect(min([-1, -2, 0, 20, -2.2])).toBe(-2.2); + }); + it('max()', () => { + expect(max([1, 2, 3])).toBe(3); + expect(max([-1, -2, 0, 20])).toBe(20); + expect(max([-1, -2, 0, -2.2])).toBe(0); + }); + it('random()', () => { + const num = random(1, 5); + expect(num).toBeLessThanOrEqual(5); + expect(num).toBeGreaterThanOrEqual(1); + }); + it('up()', () => { + expect(up(1, 5)).toEqual([1, 2, 3, 4, 5]); + expect(up(-1, 5)).toEqual([-1, 0, 1, 2, 3, 4, 5]); + }); + it('down()', () => { + expect(down(5, 1)).toEqual([5, 4, 3, 2, 1]); + expect(down(5, -1)).toEqual([5, 4, 3, 2, 1, 0, -1]); + }); +});