/*
* Copyright (c) 2017 OBiBa. All rights reserved.
*
* This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.obiba.magma.js.methods;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
import org.jscience.physics.unit.system.SI;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.NativeArray;
import org.mozilla.javascript.Scriptable;
import org.obiba.magma.Value;
import org.obiba.magma.ValueSequence;
import org.obiba.magma.ValueType;
import org.obiba.magma.js.MagmaJsEvaluationRuntimeException;
import org.obiba.magma.js.ScriptableValue;
import org.obiba.magma.type.BooleanType;
import org.obiba.magma.type.DecimalType;
import org.obiba.magma.type.IntegerType;
import org.obiba.magma.type.TextType;
import org.unitsofmeasurement.unit.Unit;
@SuppressWarnings({ "UnusedParameters", "UnusedDeclaration" })
public class NumericMethods {
private NumericMethods() {
}
private enum Ops {
DIVIDE() {
@Override
public BigDecimal operate(BigDecimal lhs, BigDecimal rhs) {
return lhs.divide(rhs, MathContext.DECIMAL128);
}
@Override
public Unit<?> operate(Unit<?> lhs, Unit<?> rhs) {
return lhs.divide(rhs);
}
},
MINUS() {
@Override
public BigDecimal operate(BigDecimal lhs, BigDecimal rhs) {
return lhs.subtract(rhs);
}
},
MULTIPLY() {
@Override
public BigDecimal operate(BigDecimal lhs, BigDecimal rhs) {
return lhs.multiply(rhs);
}
@Override
public Unit<?> operate(Unit<?> lhs, Unit<?> rhs) {
return lhs.multiply(rhs);
}
},
PLUS() {
@Override
public BigDecimal operate(BigDecimal lhs, BigDecimal rhs) {
return lhs.add(rhs);
}
};
/**
* Performs this operation on the provided values and returns the result
*
* @param lhs
* @param rhs
* @return
* @throws ArithmeticException when the operation cannot be performed on the operands (division by zero)
*/
public abstract BigDecimal operate(BigDecimal lhs, BigDecimal rhs) throws ArithmeticException;
public Unit<?> operate(Unit<?> lhs, Unit<?> rhs) {
return lhs;
}
}
private enum Comps {
GT() {
@Override
public boolean apply(int value) {
return value > 0;
}
},
GE() {
@Override
public boolean apply(int value) {
return value >= 0;
}
},
LT() {
@Override
public boolean apply(int value) {
return value < 0;
}
},
LE() {
@Override
public boolean apply(int value) {
return value <= 0;
}
},
EQ() {
@Override
public boolean apply(int value) {
return value == 0;
}
};
public abstract boolean apply(int value);
}
private enum Unary {
ABS() {
@Override
public BigDecimal operate(BigDecimal value, Object... args) {
return value.abs();
}
},
POW() {
@Override
public BigDecimal operate(BigDecimal value, Object... args) {
BigDecimal power = asBigDecimal(args[0]);
try {
int intPower = power.intValueExact();
return value.pow(intPower);
} catch(ArithmeticException e) {
return BigDecimal.valueOf(Math.pow(value.doubleValue(), power.doubleValue()));
}
}
@Override
public Unit<?> operate(Unit<?> unit, Object... args) {
BigDecimal power = asBigDecimal(args[0]);
try {
int intPower = power.intValueExact();
return unit.pow(intPower);
} catch(ArithmeticException e) {
return SI.ONE;
}
}
},
ROOT() {
@Override
public BigDecimal operate(BigDecimal value, Object... args) {
if(args[0] instanceof Integer) {
int intRoot = (Integer) args[0];
switch(intRoot) {
case 2:
return BigDecimal.valueOf(Math.sqrt(value.doubleValue()));
case 3:
return BigDecimal.valueOf(Math.cbrt(value.doubleValue()));
}
}
BigDecimal root = asBigDecimal(args[0]);
return BigDecimal.valueOf(Math.pow(value.doubleValue(), 1 / root.doubleValue()));
}
@Override
public Unit<?> operate(Unit<?> unit, Object... args) {
BigDecimal root = asBigDecimal(args[0]);
try {
int intRoot = root.intValueExact();
return unit.root(intRoot);
} catch(ArithmeticException e) {
return SI.ONE;
}
}
},
LOG() {
@Override
public BigDecimal operate(BigDecimal value, Object... args) {
double log = Math.log10(value.doubleValue());
if(args.length > 0) {
double base = asBigDecimal(args[0]).doubleValue();
log = log / Math.log10(base);
}
return BigDecimal.valueOf(log);
}
},
LN() {
@Override
public BigDecimal operate(BigDecimal value, Object... args) {
return BigDecimal.valueOf(Math.log(value.doubleValue()));
}
};
/**
* Performs this operation on the provided value and returns the result
*
* @param value
* @return
* @throws ArithmeticException when the operation cannot be performed on the operands (division by zero)
*/
public abstract BigDecimal operate(BigDecimal value, Object... args) throws ArithmeticException;
public Unit<?> operate(Unit<?> unit, Object... args) {
return unit;
}
}
/**
* Returns a new {@link ScriptableValue} containing the sum of the caller and the supplied parameter. If both operands
* are of IntegerType then the returned type will also be IntegerType, otherwise the returned type is DecimalType.
* <p/>
* <pre>
* $('NumberVarOne').plus($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue plus(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Ops.PLUS);
}
/**
* Returns a new {@link ScriptableValue} containing the result of the supplied parameter subtracted from the caller.
* If both operands are of IntegerType then the returned type will also be IntegerType, otherwise the returned type is
* DecimalType.
* <p/>
* <pre>
* $('NumberVarOne').minus($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue minus(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Ops.MINUS);
}
/**
* Returns a new {@link ScriptableValue} containing the result of the supplied parameter multiplied by the caller. If
* both operands are of IntegerType then the returned type will also be IntegerType, otherwise the returned type is
* DecimalType.
* <p/>
* <pre>
* $('NumberVarOne').multiply($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue multiply(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Ops.MULTIPLY);
}
/**
* Returns a new {@link ScriptableValue} containing the result of the caller divided by the supplied parameter. The
* return type is always DecimalType.
* <p/>
* <pre>
* $('NumberVarOne').div($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue div(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Ops.DIVIDE);
}
/**
* Returns a new {@link ScriptableValue} of the {@link BooleanType} indicating if the first parameter is greater than
* the second parameter.
* <p/>
* <pre>
* $('NumberVarOne').gt($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue gt(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return new ScriptableValue(thisObj, compare((ScriptableValue) thisObj, args, Comps.GT));
}
/**
* Returns a new {@link ScriptableValue} of the {@link BooleanType} indicating if the first parameter is greater than
* or equal the second parameter.
* <p/>
* <pre>
* $('NumberVarOne').ge($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue ge(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return new ScriptableValue(thisObj, compare((ScriptableValue) thisObj, args, Comps.GE));
}
/**
* Returns a new {@link ScriptableValue} of the {@link BooleanType} indicating if the first parameter is less than the
* second parameter.
* <p/>
* <pre>
* $('NumberVarOne').lt($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue lt(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return new ScriptableValue(thisObj, compare((ScriptableValue) thisObj, args, Comps.LT));
}
/**
* Returns a new {@link ScriptableValue} of the {@link BooleanType} indicating if the first parameter is less than or
* equal the second parameter.
* <p/>
* <pre>
* $('NumberVarOne').le($('NumberVarTwo'))
* </pre>
*
* @throws MagmaJsEvaluationRuntimeException if operands are not ScriptableValue Objects of IntegerType or
* DecimalType.
*/
public static ScriptableValue le(Context ctx, Scriptable thisObj, Object[] args, @Nullable Function funObj)
throws MagmaJsEvaluationRuntimeException {
return new ScriptableValue(thisObj, compare((ScriptableValue) thisObj, args, Comps.LE));
}
/**
* Returns the absolute value of the input value.
* <p/>
* <pre>
* $('NumberVarOne').abs()
* </pre>
*/
public static ScriptableValue abs(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Unary.ABS);
}
/**
* Returns a new {@link ScriptableValue} that is the natural logarithm of this value.
* <p/>
* <pre>
* $('NumberVarOne').ln()
* </pre>
*/
public static ScriptableValue ln(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Unary.LN);
}
/**
* Returns a new {@link ScriptableValue} that is the natural logarithm of this value.
* <p/>
* <pre>
* $('NumberVarOne').log() // log base 10
* $('NumberVarOne').log(2) // log base 2
* </pre>
*/
public static ScriptableValue log(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Unary.LOG);
}
/**
* Returns a new {@link ScriptableValue} that is the value raised to the specified power.
* <p/>
* <pre>
* $('NumberVarOne').pow(2)
* $('NumberVarOne').pow(-2)
* </pre>
*/
public static ScriptableValue pow(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Unary.POW);
}
/**
* Returns a new {@link ScriptableValue} that is the value's {@code root} root.
* <p/>
* <pre>
* $('NumberVarOne').sqroot() // square root
* $('NumberVarOne').cbroot() // cubic root
* $('NumberVarOne').root(42) // arbitrary root
* </pre>
*/
public static ScriptableValue root(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, args, Unary.ROOT);
}
public static ScriptableValue sqroot(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, new Object[] { 2 }, Unary.ROOT);
}
public static ScriptableValue cbroot(Context ctx, Scriptable thisObj, Object[] args, Function funObj)
throws MagmaJsEvaluationRuntimeException {
return operate((ScriptableValue) thisObj, new Object[] { 3 }, Unary.ROOT);
}
/**
* Groups variables in continuous space into discrete space given a list of adjacent range limits. When the current
* value is not an integer a null value is returned.
* <p/>
* <pre>
* // usage example, possible returned values are: '-18', '18-35', '35-40', ..., '70+'
* $('CURRENT_AGE').group([18,35,40,45,50,55,60,65,70]);
*
* // support of optional outliers
* $('CURRENT_AGE').group([18,35,40,45,50,55,60,65,70],[888,999]);
*
* // in combination with map
* $('CURRENT_AGE').group([30,40,50,60],[888,999]).map({
* '-30' : 1,
* '30-40': 2,
* '40-50': 3,
* '50-60': 4,
* '60+': 5,
* '888': 88,
* '999': 99
* });
* </pre>
*
* @param ctx
* @param thisObj
* @param args
* @return funObj
*/
public static ScriptableValue group(Context ctx, Scriptable thisObj, Object[] args, Function funObj) {
if(args == null || args.length < 1 || !(args[0] instanceof NativeArray)) {
throw new MagmaJsEvaluationRuntimeException("illegal arguments to group()");
}
if(args.length == 2 && !(args[1] instanceof NativeArray)) {
throw new MagmaJsEvaluationRuntimeException("illegal arguments to group()");
}
ScriptableValue sv = (ScriptableValue) thisObj;
Value currentValue = sv.getValue();
List<Value> boundaries = boundaryValues(sv.getValueType(), args);
List<Value> outliers = outlierValues(sv.getValueType(), args);
if(currentValue.isSequence()) {
if(currentValue.isNull()) {
return new ScriptableValue(thisObj, TextType.get().nullSequence());
}
Collection<Value> newValues = new ArrayList<>();
for(Value value : currentValue.asSequence().getValue()) {
newValues.add(lookupGroup(ctx, thisObj, value, boundaries, outliers));
}
return new ScriptableValue(thisObj, TextType.get().sequenceOf(newValues));
}
return new ScriptableValue(thisObj, lookupGroup(ctx, thisObj, currentValue, boundaries, outliers));
}
/**
* Returns the boundary list value to be used when present. Otherwise use the corresponding range. This method is used
* by the group() method.
*
* @param valueType
* @param args
* @return
*/
private static List<Value> boundaryValues(ValueType valueType, Object... args) {
return nativeArrayToValueList(valueType, args[0]);
}
/**
* Returns the outlier list value to be used when present. Otherwise use the corresponding range. This method is used
* by the group() method.
*
* @param valueType
* @param args
* @return
*/
private static List<Value> outlierValues(ValueType valueType, Object... args) {
return args.length > 1 ? nativeArrayToValueList(valueType, args[1]) : null;
}
/**
* Returns a List of Value from a NativeArray. This method is used by boundaryValues() and outlierValues(). by the
* group() method.
*
* @param valueType
* @param args
* @return
*/
private static List<Value> nativeArrayToValueList(ValueType valueType, Object array) {
NativeArray a = (NativeArray) array;
List<Value> newValues = new ArrayList<>();
Value newValue;
for(int index = 0; index < (int) a.getLength(); index++) {
newValue = valueType.valueOf(a.get(index, a));
newValues.add(index, newValue);
}
Collections.sort(newValues);
return newValues;
}
/**
* Lookup {@code value} within {@code boundaries} and return the corresponding group of type integer
*
* @param ctx
* @param thisObj
* @param value
* @param boundaries
* @param outliers
* @return
*/
private static Value lookupGroup(Context ctx, Scriptable thisObj, Value value, Iterable<Value> boundaries,
Collection<Value> outliers) {
if(outliers != null && outliers.contains(value)) {
return TextType.get().convert(value);
}
if(value.isNull()) {
return TextType.get().nullValue();
}
Value lowerBound = null;
// boundaries are ordered
for(Value upperBound : boundaries) {
if(value.compareTo(upperBound) < 0) {
return lowerBound == null //
? TextType.get().valueOf("-" + formatNumberValue(upperBound)) //
: TextType.get().valueOf(formatNumberValue(lowerBound) + "-" + formatNumberValue(upperBound));
}
lowerBound = upperBound;
}
if(lowerBound != null && value.compareTo(lowerBound) >= 0) {
return TextType.get().valueOf(formatNumberValue(lowerBound) + "+");
}
// no boundaries
return TextType.get().valueOf(formatNumberValue(value));
}
/**
* Remove the trailing '.0' that appear for doubles in java, but not in javascript.
*
* @param value
* @return
*/
private static String formatNumberValue(Value value) {
if(value.isNull()) return null;
String str = value.toString();
if(str == null) return null;
return str.endsWith(".0") ? str.substring(0, str.length() - 2) : str;
}
static Value equals(ScriptableValue thisObj, Object... args) {
return compare(thisObj, args, Comps.EQ);
}
static Value compare(ScriptableValue thisObj, Object args[], Comps comparator) {
BigDecimal value = asBigDecimal(thisObj);
if(value == null) return BooleanType.get().nullValue();
for(Object argument : args) {
BigDecimal rhs = asBigDecimal(argument);
if(rhs == null) return BooleanType.get().nullValue();
if(!comparator.apply(value.compareTo(rhs))) {
return BooleanType.get().falseValue();
}
}
return BooleanType.get().trueValue();
}
static ScriptableValue operate(ScriptableValue thisObj, Object args[], Unary operation) {
try {
BigDecimal value = asBigDecimal(thisObj);
if(value == null) return new ScriptableValue(thisObj, thisObj.getValueType().nullValue());
value = operation.operate(value, args);
Unit<?> unit = operation.operate(UnitMethods.extractUnit(thisObj), args);
try {
long longValue = value.longValueExact();
return new ScriptableValue(thisObj, IntegerType.get().valueOf(longValue), unit.toString());
} catch(ArithmeticException e) {
return new ScriptableValue(thisObj, DecimalType.get().valueOf(value.doubleValue()), unit.toString());
}
} catch(ArithmeticException e) {
return new ScriptableValue(thisObj, DecimalType.get().nullValue());
}
}
static ScriptableValue operate(ScriptableValue thisObj, Object args[], Ops operation) {
try {
BigDecimal value = asBigDecimal(thisObj);
if(value == null) return new ScriptableValue(thisObj, thisObj.getValueType().nullValue());
Unit<?> unit = UnitMethods.extractUnit(thisObj);
for(Object argument : args) {
BigDecimal rhs = asBigDecimal(argument);
if(rhs == null) return new ScriptableValue(thisObj, thisObj.getValueType().nullValue());
value = operation.operate(value, rhs);
unit = operation.operate(unit, UnitMethods.extractUnit(argument));
}
try {
long longValue = value.longValueExact();
return new ScriptableValue(thisObj, IntegerType.get().valueOf(longValue), unit.toString());
} catch(ArithmeticException e) {
return new ScriptableValue(thisObj, DecimalType.get().valueOf(value.doubleValue()), unit.toString());
}
} catch(ArithmeticException e) {
return new ScriptableValue(thisObj, DecimalType.get().nullValue());
}
}
static Double asDouble(Object obj) {
if(obj == null) return null;
if(obj instanceof Number) {
return ((Number) obj).doubleValue();
}
if(obj instanceof ScriptableValue) {
ScriptableValue sv = (ScriptableValue) obj;
Value value = sv.getValue();
return value.isNull() ? null : ((Number) value.getValue()).doubleValue();
}
if(obj instanceof String) {
return Double.valueOf((String) obj);
}
throw new IllegalArgumentException("cannot interpret argument as number: '" + obj + "'");
}
static BigDecimal asBigDecimal(Object object) {
if(object == null) return null;
if(object instanceof ScriptableValue) {
return asBigDecimal((ScriptableValue) object);
}
if(object instanceof Number) {
return new BigDecimal(object.toString());
}
if(object instanceof String) {
return new BigDecimal((String) object);
}
throw new IllegalArgumentException("cannot interpret argument as number: '" + object + "'");
}
static BigDecimal asBigDecimal(ScriptableValue scriptableValue) {
if(scriptableValue == null) throw new IllegalArgumentException("value cannot be null");
Value value = scriptableValue.getValue();
if(value.isNull()) {
// Throw a runtime exception if the null value provided in scriptableValue argument is not convertible to decimal.
// This is to manipulate the null value only created by a "Number" Type.
ValueType.Factory.converterFor(scriptableValue.getValueType(), DecimalType.get());
return null;
}
if(scriptableValue.getValueType().isNumeric()) {
return new BigDecimal(((Number) value.getValue()).doubleValue());
}
Value decimalValue = DecimalType.get().convert(value);
return new BigDecimal((Double) decimalValue.getValue());
}
static Double min(ValueSequence valueSequence) {
if(valueSequence.isNull()) return null;
Double min = null;
for(Value v : valueSequence.getValue()) {
if(!v.isNull()) {
double doubleValue = ((Number) v.getValue()).doubleValue();
if (min == null) min = doubleValue;
else min = Math.min(doubleValue, min);
}
}
return min;
}
static Double max(ValueSequence valueSequence) {
if(valueSequence.isNull()) return null;
Double max = null;
for(Value v : valueSequence.getValue()) {
if(!v.isNull()) {
double doubleValue = ((Number) v.getValue()).doubleValue();
if (max == null) max = doubleValue;
else max = Math.max(doubleValue, max);
}
}
return max;
}
static Double sum(ValueSequence valueSequence) {
if(valueSequence.isNull()) return null;
double sum = 0;
for(Value v : valueSequence.getValue()) {
if(!v.isNull()) {
sum += ((Number) v.getValue()).doubleValue();
}
}
return sum;
}
static Double average(ValueSequence valueSequence) {
int size = valueSequence.getSize();
if(size == 0) return null;
Double sum = sum(valueSequence);
if(sum != null) {
return sum / size;
}
return null;
}
static Double stddev(ValueSequence valueSequence) {
if(valueSequence.isNull()) return null;
Double avg = average(valueSequence);
if(avg == null) return null;
int size = valueSequence.getSize();
if(size == 0) return null;
double sumDev = 0;
for(Value v : valueSequence.getValue()) {
if(!v.isNull()) {
double d = ((Number) v.getValue()).doubleValue();
sumDev += (d - avg) * (d - avg);
}
}
return Math.sqrt(sumDev / size);
}
}