Browse Source

Math Function rule node

pull/7348/head
Andrii Shvaika 4 years ago
parent
commit
f9b7f6540e
  1. 6
      pom.xml
  2. 6
      rule-engine/rule-engine-components/pom.xml
  3. 5
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathArgument.java
  4. 29
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java
  5. 1
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java
  6. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbRuleNodeMathFunctionType.java
  7. 118
      rule-engine/rule-engine-components/src/test/java/math/TbMathNodeTest.java

6
pom.xml

@ -137,6 +137,7 @@
<zeroturnaround.version>1.12</zeroturnaround.version> <zeroturnaround.version>1.12</zeroturnaround.version>
<opensmpp.version>3.0.0</opensmpp.version> <opensmpp.version>3.0.0</opensmpp.version>
<jgit.version>6.1.0.202203080745-r</jgit.version> <jgit.version>6.1.0.202203080745-r</jgit.version>
<exp4j.version>0.4.8</exp4j.version>
<aerogear-otp.version>1.0.0</aerogear-otp.version> <aerogear-otp.version>1.0.0</aerogear-otp.version>
</properties> </properties>
@ -1911,6 +1912,11 @@
<artifactId>org.eclipse.jgit.ssh.apache</artifactId> <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
<version>${jgit.version}</version> <version>${jgit.version}</version>
</dependency> </dependency>
<dependency>
<groupId>net.objecthunter</groupId>
<artifactId>exp4j</artifactId>
<version>${exp4j.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

6
rule-engine/rule-engine-components/pom.xml

@ -121,6 +121,10 @@
<artifactId>javax.mail</artifactId> <artifactId>javax.mail</artifactId>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>net.objecthunter</groupId>
<artifactId>exp4j</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@ -139,10 +143,12 @@
<dependency> <dependency>
<groupId>org.mock-server</groupId> <groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId> <artifactId>mockserver-netty</artifactId>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mock-server</groupId> <groupId>org.mock-server</groupId>
<artifactId>mockserver-client-java</artifactId> <artifactId>mockserver-client-java</artifactId>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>

5
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathArgument.java

@ -24,13 +24,14 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
public class TbMathArgument { public class TbMathArgument {
private String name;
private TbMathArgumentType type; private TbMathArgumentType type;
private String key; private String key;
private String attributeScope; private String attributeScope;
private Double defaultValue; private Double defaultValue;
public TbMathArgument(TbMathArgumentType type, String key) { public TbMathArgument(TbMathArgumentType type, String key) {
this.type = type; this(key, type, key, null, null);
this.key = key;
} }
} }

29
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java

@ -20,6 +20,8 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.objecthunter.exp4j.Expression;
import net.objecthunter.exp4j.ExpressionBuilder;
import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
@ -57,6 +59,7 @@ import java.util.stream.Collectors;
configClazz = TbMathNodeConfiguration.class, configClazz = TbMathNodeConfiguration.class,
nodeDescription = "Apply math function and save the result into the message and/or database", nodeDescription = "Apply math function and save the result into the message and/or database",
nodeDetails = "Supports math operations like: ADD, SUB, MULT, DIV, etc and functions: SIN, COS, TAN, SEC, etc. " + nodeDetails = "Supports math operations like: ADD, SUB, MULT, DIV, etc and functions: SIN, COS, TAN, SEC, etc. " +
"Use 'CUSTOM' operation to specify complex math expressions." +
"<br/><br/>" + "<br/><br/>" +
"You may use constant, message field, metadata field, attribute, and latest time-series as an arguments values. " + "You may use constant, message field, metadata field, attribute, and latest time-series as an arguments values. " +
"The result of the function may be also stored to message field, metadata field, attribute or time-series value." + "The result of the function may be also stored to message field, metadata field, attribute or time-series value." +
@ -65,15 +68,17 @@ import java.util.stream.Collectors;
"For example, you may increase `totalWaterConsumption` based on the `deltaWaterConsumption` reported by device." + "For example, you may increase `totalWaterConsumption` based on the `deltaWaterConsumption` reported by device." +
"<br/><br/>" + "<br/><br/>" +
"Alternative use case is the replacement of simple JS `script` nodes with more light-weight and performant implementation. " + "Alternative use case is the replacement of simple JS `script` nodes with more light-weight and performant implementation. " +
"For example, you may transform Fahrenheit to Celsius (C = (F - 32) / 1.8) using combination of two math node functions: SUB 32 and DIV 1.8." + "For example, you may transform Fahrenheit to Celsius (C = (F - 32) / 1.8) using CUSTOM operation and expression: (x - 32) / 1.8)." +
"<br/><br/>" + "<br/><br/>" +
"The execution is synchronized in scope of message originator (e.g. device) and server node. " + "The execution is synchronized in scope of message originator (e.g. device) and server node. " +
"If you have rule nodes in different rule chains, they will process messages from the same originator synchronously in the scope of the server node.", "If you have rule nodes in different rule chains, they will process messages from the same originator synchronously in the scope of the server node.",
icon = "functions" icon = "functions"
) )
public class TbMathNode implements TbNode { public class TbMathNode implements TbNode {
private static final ConcurrentMap<EntityId, Semaphore> semaphores = new ConcurrentReferenceHashMap<>(); private static final ConcurrentMap<EntityId, Semaphore> semaphores = new ConcurrentReferenceHashMap<>();
private final ThreadLocal<Expression> customExpression = new ThreadLocal<>();
private TbMathNodeConfiguration config; private TbMathNodeConfiguration config;
private boolean msgBodyToJsonConversionRequired; private boolean msgBodyToJsonConversionRequired;
@ -86,6 +91,13 @@ public class TbMathNode implements TbNode {
if (argsCount < operation.getMinArgs() || argsCount > operation.getMaxArgs()) { if (argsCount < operation.getMinArgs() || argsCount > operation.getMaxArgs()) {
throw new RuntimeException("Args count: " + argsCount + " does not match operation: " + operation.name()); throw new RuntimeException("Args count: " + argsCount + " does not match operation: " + operation.name());
} }
if (TbRuleNodeMathFunctionType.CUSTOM.equals(operation)) {
if (StringUtils.isBlank(config.getCustomFunction())) {
throw new RuntimeException("Custom function is blank!");
} else if (config.getCustomFunction().length() > 256) {
throw new RuntimeException("Custom function is too complex (length > 256)!");
}
}
msgBodyToJsonConversionRequired = config.getArguments().stream().anyMatch(arg -> TbMathArgumentType.MESSAGE_BODY.equals(arg.getType())); msgBodyToJsonConversionRequired = config.getArguments().stream().anyMatch(arg -> TbMathArgumentType.MESSAGE_BODY.equals(arg.getType()));
msgBodyToJsonConversionRequired = msgBodyToJsonConversionRequired || TbMathArgumentType.MESSAGE_BODY.equals(config.getResult().getType()); msgBodyToJsonConversionRequired = msgBodyToJsonConversionRequired || TbMathArgumentType.MESSAGE_BODY.equals(config.getResult().getType());
} }
@ -304,6 +316,19 @@ public class TbMathNode implements TbNode {
return apply(args.get(0), Math::toRadians); return apply(args.get(0), Math::toRadians);
case DEG: case DEG:
return apply(args.get(0), Math::toDegrees); return apply(args.get(0), Math::toDegrees);
case CUSTOM:
var expr = customExpression.get();
if (expr == null) {
expr = new ExpressionBuilder(config.getCustomFunction())
.implicitMultiplication(true)
.variables(config.getArguments().stream().map(TbMathArgument::getName).collect(Collectors.toSet()))
.build();
customExpression.set(expr);
}
for (int i = 0; i < config.getArguments().size(); i++) {
expr.setVariable(config.getArguments().get(i).getName(), args.get(i).getValue());
}
return expr.evaluate();
default: default:
throw new RuntimeException("Not supported operation: " + config.getOperation()); throw new RuntimeException("Not supported operation: " + config.getOperation());
} }
@ -329,7 +354,7 @@ public class TbMathNode implements TbNode {
String scope = getAttributeScope(arg.getAttributeScope()); String scope = getAttributeScope(arg.getAttributeScope());
return Futures.transform(ctx.getAttributesService().find(ctx.getTenantId(), msg.getOriginator(), scope, arg.getKey()), return Futures.transform(ctx.getAttributesService().find(ctx.getTenantId(), msg.getOriginator(), scope, arg.getKey()),
opt -> getTbMathArgumentValue(arg, opt, "Attribute: " + arg.getKey() + " with scope: " + scope + " not found for entity: " + msg.getOriginator()) opt -> getTbMathArgumentValue(arg, opt, "Attribute: " + arg.getKey() + " with scope: " + scope + " not found for entity: " + msg.getOriginator())
,MoreExecutors.directExecutor()); , MoreExecutors.directExecutor());
case TIME_SERIES: case TIME_SERIES:
return Futures.transform(ctx.getTimeseriesService().findLatest(ctx.getTenantId(), msg.getOriginator(), arg.getKey()), return Futures.transform(ctx.getTimeseriesService().findLatest(ctx.getTenantId(), msg.getOriginator(), arg.getKey()),
opt -> getTbMathArgumentValue(arg, opt, "Time-series: " + arg.getKey() + " not found for entity: " + msg.getOriginator()) opt -> getTbMathArgumentValue(arg, opt, "Time-series: " + arg.getKey() + " not found for entity: " + msg.getOriginator())

1
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java

@ -26,6 +26,7 @@ public class TbMathNodeConfiguration implements NodeConfiguration<TbMathNodeConf
private TbRuleNodeMathFunctionType operation; private TbRuleNodeMathFunctionType operation;
private List<TbMathArgument> arguments; private List<TbMathArgument> arguments;
private String customFunction;
private TbMathResult result; private TbMathResult result;
@Override @Override

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbRuleNodeMathFunctionType.java

@ -23,7 +23,9 @@ public enum TbRuleNodeMathFunctionType {
SIN, SINH, COS, COSH, TAN, TANH, ACOS, ASIN, ATAN, ATAN2(2), SIN, SINH, COS, COSH, TAN, TANH, ACOS, ASIN, ATAN, ATAN2(2),
EXP, EXPM1, SQRT, CBRT, GET_EXP(1, 1, true), HYPOT(2), LOG, LOG10, LOG1P, EXP, EXPM1, SQRT, CBRT, GET_EXP(1, 1, true), HYPOT(2), LOG, LOG10, LOG1P,
CEIL(1, 1, true), FLOOR(1, 1, true), FLOOR_DIV(2), FLOOR_MOD(2), CEIL(1, 1, true), FLOOR(1, 1, true), FLOOR_DIV(2), FLOOR_MOD(2),
ABS, MIN(2), MAX(2), POW, SIGNUM, RAD, DEG; ABS, MIN(2), MAX(2), POW, SIGNUM, RAD, DEG,
CUSTOM(0, 16, false); //Custom function based on exp4j
@Getter @Getter
private final int minArgs; private final int minArgs;

118
rule-engine/rule-engine-components/src/test/java/math/TbMathNodeTest.java

@ -33,8 +33,8 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.math.TbMathArgument; import org.thingsboard.rule.engine.math.TbMathArgument;
import org.thingsboard.rule.engine.math.TbMathArgumentType; import org.thingsboard.rule.engine.math.TbMathArgumentType;
import org.thingsboard.rule.engine.math.TbMathNodeConfiguration;
import org.thingsboard.rule.engine.math.TbMathNode; import org.thingsboard.rule.engine.math.TbMathNode;
import org.thingsboard.rule.engine.math.TbMathNodeConfiguration;
import org.thingsboard.rule.engine.math.TbMathResult; import org.thingsboard.rule.engine.math.TbMathResult;
import org.thingsboard.rule.engine.math.TbRuleNodeMathFunctionType; import org.thingsboard.rule.engine.math.TbRuleNodeMathFunctionType;
import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DataConstants;
@ -79,6 +79,15 @@ public class TbMathNodeTest {
} }
}; };
dbExecutor.init(); dbExecutor.init();
initMocks();
}
@After
public void after() {
dbExecutor.destroy();
}
private void initMocks() {
Mockito.reset(ctx); Mockito.reset(ctx);
Mockito.reset(attributesService); Mockito.reset(attributesService);
Mockito.reset(tsService); Mockito.reset(tsService);
@ -88,16 +97,21 @@ public class TbMathNodeTest {
lenient().when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); lenient().when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor);
} }
@After private TbMathNode initNode(TbRuleNodeMathFunctionType operation, TbMathResult result, TbMathArgument... arguments) {
public void after() { return initNode(operation, null, result, arguments);
dbExecutor.destroy();
} }
private TbMathNode initNodeWithCustomFunction(String expression, TbMathResult result, TbMathArgument... arguments) {
return initNode(TbRuleNodeMathFunctionType.CUSTOM, expression, result, arguments);
}
private TbMathNode initNode(TbRuleNodeMathFunctionType operation, TbMathResult result, TbMathArgument... arguments) { private TbMathNode initNode(TbRuleNodeMathFunctionType operation, String expression, TbMathResult result, TbMathArgument... arguments) {
try { try {
TbMathNodeConfiguration configuration = new TbMathNodeConfiguration(); TbMathNodeConfiguration configuration = new TbMathNodeConfiguration();
configuration.setOperation(operation); configuration.setOperation(operation);
if (TbRuleNodeMathFunctionType.CUSTOM.equals(operation)) {
configuration.setCustomFunction(expression);
}
configuration.setResult(result); configuration.setResult(result);
configuration.setArguments(Arrays.asList(arguments)); configuration.setArguments(Arrays.asList(arguments));
TbMathNode node = new TbMathNode(); TbMathNode node = new TbMathNode();
@ -108,6 +122,100 @@ public class TbMathNodeTest {
} }
} }
@Test
public void testExp4j() {
var node = initNodeWithCustomFunction("2a+3b",
new TbMathResult(TbMathArgumentType.MESSAGE_BODY, "result", 2, false, false, null),
new TbMathArgument(TbMathArgumentType.MESSAGE_BODY, "a"),
new TbMathArgument(TbMathArgumentType.MESSAGE_BODY, "b")
);
TbMsg msg = TbMsg.newMsg("TEST", originator, new TbMsgMetaData(), JacksonUtil.newObjectNode().put("a", 2).put("b", 2).toString());
node.onMsg(ctx, msg);
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
Mockito.verify(ctx, Mockito.timeout(5000)).tellSuccess(msgCaptor.capture());
TbMsg resultMsg = msgCaptor.getValue();
Assert.assertNotNull(resultMsg);
Assert.assertNotNull(resultMsg.getData());
var resultJson = JacksonUtil.toJsonNode(resultMsg.getData());
Assert.assertTrue(resultJson.has("result"));
Assert.assertEquals(10, resultJson.get("result").asInt());
}
@Test
public void testSimpleFunctions() {
testSimpleTwoArgumentFunction(TbRuleNodeMathFunctionType.ADD, 2.1, 2.2, 4.3);
testSimpleTwoArgumentFunction(TbRuleNodeMathFunctionType.SUB, 2.1, 2.2, -0.1);
testSimpleTwoArgumentFunction(TbRuleNodeMathFunctionType.MULT, 2.1, 2.0, 4.2);
testSimpleTwoArgumentFunction(TbRuleNodeMathFunctionType.DIV, 4.2, 2.0, 2.1);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.SIN, Math.toRadians(30), 0.5);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.SIN, Math.toRadians(90), 1.0);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.SINH, Math.toRadians(0), 0.0);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.COSH, Math.toRadians(0), 1.0);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.COS, Math.toRadians(60), 0.5);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.COS, Math.toRadians(0), 1.0);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.TAN, Math.toRadians(45), 1);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.TAN, Math.toRadians(0), 0);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.ABS, -1, 1);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.SQRT, 4, 2);
testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType.CBRT, 8, 2);
}
private void testSimpleTwoArgumentFunction(TbRuleNodeMathFunctionType function, double arg1, double arg2, double result) {
initMocks();
var node = initNode(function,
new TbMathResult(TbMathArgumentType.MESSAGE_BODY, "result", 2, false, false, null),
new TbMathArgument(TbMathArgumentType.MESSAGE_BODY, "a"),
new TbMathArgument(TbMathArgumentType.MESSAGE_BODY, "b")
);
TbMsg msg = TbMsg.newMsg("TEST", originator, new TbMsgMetaData(), JacksonUtil.newObjectNode().put("a", arg1).put("b", arg2).toString());
node.onMsg(ctx, msg);
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
Mockito.verify(ctx, Mockito.timeout(5000).times(1)).tellSuccess(msgCaptor.capture());
TbMsg resultMsg = msgCaptor.getValue();
Assert.assertNotNull(resultMsg);
Assert.assertNotNull(resultMsg.getData());
var resultJson = JacksonUtil.toJsonNode(resultMsg.getData());
Assert.assertTrue(resultJson.has("result"));
Assert.assertEquals(result, resultJson.get("result").asDouble(), 0d);
}
private void testSimpleOneArgumentFunction(TbRuleNodeMathFunctionType function, double arg1, double result) {
initMocks();
var node = initNode(function,
new TbMathResult(TbMathArgumentType.MESSAGE_BODY, "result", 2, false, false, null),
new TbMathArgument(TbMathArgumentType.MESSAGE_BODY, "a")
);
TbMsg msg = TbMsg.newMsg("TEST", originator, new TbMsgMetaData(), JacksonUtil.newObjectNode().put("a", arg1).toString());
node.onMsg(ctx, msg);
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
Mockito.verify(ctx, Mockito.timeout(5000)).tellSuccess(msgCaptor.capture());
TbMsg resultMsg = msgCaptor.getValue();
Assert.assertNotNull(resultMsg);
Assert.assertNotNull(resultMsg.getData());
var resultJson = JacksonUtil.toJsonNode(resultMsg.getData());
Assert.assertTrue(resultJson.has("result"));
Assert.assertEquals(result, resultJson.get("result").asDouble(), 0d);
}
@Test @Test
public void test_2_plus_2_body() { public void test_2_plus_2_body() {
var node = initNode(TbRuleNodeMathFunctionType.ADD, var node = initNode(TbRuleNodeMathFunctionType.ADD,

Loading…
Cancel
Save