diff --git a/Blocks/CMakeLists.txt b/Blocks/CMakeLists.txt new file mode 100644 index 0000000..21c2793 --- /dev/null +++ b/Blocks/CMakeLists.txt @@ -0,0 +1,13 @@ +######################################################################## +## Build and install +######################################################################## +POTHOS_PYTHON_UTIL( + TARGET PythonBlocks + SOURCES + __init__.py + Evaluator.py + FACTORIES + "/python/evaluator:Evaluator" + DESTINATION PothosBlocks + ENABLE_DOCS +) diff --git a/Blocks/Evaluator.py b/Blocks/Evaluator.py new file mode 100644 index 0000000..39c2827 --- /dev/null +++ b/Blocks/Evaluator.py @@ -0,0 +1,167 @@ +# Copyright (c) 2020-2021 Nicholas Corgan +# SPDX-License-Identifier: BSL-1.0 + +from functools import partial +import importlib + +import Pothos + +""" +/*********************************************************************** + * |PothosDoc Python Evaluator + * + * The Python evaluator block performs a user-specified expression evaluation + * on input slot(s) and produces the evaluation result on an output signal. + * The input slots are user-defined. The output signal is named "triggered". + * The arguments from the input slots must be primitive types. + * + * |category /Event + * |keywords signal slot eval expression + * + * |param imports[Imports] A list of Python modules to import before executing the expression. + * Example: ["math", "numpy"] will import the math and numpy modules. + * |default ["math"] + * + * |param args[Arguments] A list of named variables to use in the expression. + * Each variable corresponds to settings slot on the transform block. + * Example: ["foo", "bar"] will create the slots "setFoo" and "setBar". + * |default ["val"] + * + * |param expr[Expression] The expression to re-evaluate for each slot event. + * An expression is valid Python, comprised of combinations of variables, constants, and math functions. + * Example: math.log2(foo)/bar + * + *

Multi-argument input: Upstream blocks may pass multiple arguments to a slot. + * Each argument will be available to the expression suffixed by its argument index. + * For example, suppose that the slot "setBaz" has two arguments, + * then the following expression would use both arguments: "baz0 + baz1"

+ * + * |default "math.log2(val)" + * |widget StringEntry() + * + * |param localVars[LocalVars] A map of variable names to values. + * This allows you to use global variables from the topology in the expression. + * + * For example this mapping lets us use foo, bar, and baz in the expression + * to represent several different globals and combinations of expressions: + * {"foo": myGlobal, "bar": "test123", "baz": myNum+12345} + * |default {} + * |preview valid + * + * |factory /python/evaluator(args) + * |setter setExpression(expr) + * |setter setImports(imports) + * |setter setLocalVars(localVars) + **********************************************************************/ +""" +class Evaluator(Pothos.Block): + def __init__(self, varNames): + Pothos.Block.__init__(self) + self.setName("/python/evaluator") + + self.__checkIsStringList(varNames) + + self.__expr = "" + self.__localVars = dict() + self.__varNames = varNames + self.__varValues = dict() + self.__imports = [] + self.__varsReady = set() + + # Add setters for user variables + for name in self.__varNames: + if not name: + continue + + setterName = "set"+name[0].upper()+name[1:] + setattr(Evaluator, setterName, partial(self.__setter, name)) + self.registerSlot(setterName) + + self.registerSlot("setExpression") + self.registerSlot("setImports") + self.registerSlot("setLocalVars") + self.registerSignal("triggered") + + def getExpression(self): + return self.__expr + + def setExpression(self,expr): + self.__checkIsStr(expr) + self.__expr = expr + + notReadyVars = [var for var in self.__varNames if var not in self.__varsReady] + if notReadyVars: + return + + args = self.__performEval() + self.triggered(args) + + def setImports(self,imports): + self.__checkIsStringOrStringList(imports) + self.__imports = imports if (type(imports) == list) else [imports] + + def getImports(self): + return self.__imports + + def setLocalVars(self,userLocalVars): + self.__checkIsDict(userLocalVars) + self.__localVars = userLocalVars + + # + # Private utility functions + # + + def __performEval(self): + for key,val in self.__varValues.items(): + locals()[key] = val + + for mod in self.__imports: + exec("import "+mod) + + for key,val in self.__localVars.items(): + locals()[key] = val + + return eval(self.__expr) + + def __setter(self,field,*args): + if len(args) > 1: + for i in range(len(args)): + self.__varValues[field+str(i)] = args[i] + else: + self.__varValues[field] = args[0] + + self.__varsReady.add(field) + + notReadyVars = [var for var in self.__varNames if var not in self.__varsReady] + if (not notReadyVars) and self.__expr: + args = self.__performEval() + self.triggered(args) + + return None + + def __checkIsStr(self,var): + if type(var) != str: + raise ValueError("The given value must be a str. Found {0}".format(type(var))) + + def __checkIsDict(self,var): + if type(var) != dict: + raise ValueError("The given value must be a dict. Found {0}".format(type(var))) + + def __checkIsList(self,var): + if type(var) != list: + raise ValueError("The given value must be a list. Found {0}".format(type(var))) + + def __checkIsStringList(self,var): + self.__checkIsList(var) + + nonStringVals = [x for x in var if type(x) != str] + if nonStringVals: + raise ValueError("All list values must be strings. Found {0}".format(type(nonStringVals[0]))) + + def __checkIsStringOrStringList(self,var): + if type(var) is str: + return + elif type(var) is list: + self.__checkIsStringList(var) + else: + raise ValueError("The given value must be a string or list. Found {0}".format(type(var))) diff --git a/Blocks/__init__.py b/Blocks/__init__.py new file mode 100644 index 0000000..4c8603f --- /dev/null +++ b/Blocks/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2020-2021 Nicholas Corgan +# SPDX-License-Identifier: BSL-1.0 + +from . Evaluator import Evaluator diff --git a/CMakeLists.txt b/CMakeLists.txt index 86734e9..799f682 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,4 +141,5 @@ install( # Enter the subdirectory configuration ######################################################################## add_subdirectory(Pothos) +add_subdirectory(Blocks) add_subdirectory(TestBlocks) diff --git a/Pothos/__init__.py b/Pothos/__init__.py index a1f106f..4a2213c 100644 --- a/Pothos/__init__.py +++ b/Pothos/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2014-2016 Josh Blum -# 2019 Nicholas Corgan +# 2019-2020 Nicholas Corgan # SPDX-License-Identifier: BSL-1.0 from . PothosModule import * diff --git a/TestPythonBlock.cpp b/TestPythonBlock.cpp index 76d1c1e..ae2a06d 100644 --- a/TestPythonBlock.cpp +++ b/TestPythonBlock.cpp @@ -1,13 +1,22 @@ // Copyright (c) 2014-2017 Josh Blum +// 2020-2021 Nicholas Corgan // SPDX-License-Identifier: BSL-1.0 #include #include #include #include -#include + #include +#include +#include +#include + +#include +#include +#include + using json = nlohmann::json; POTHOS_TEST_BLOCK("/proxy/python/tests", python_module_import) @@ -68,3 +77,88 @@ POTHOS_TEST_BLOCK("/proxy/python/tests", test_signals_and_slots) std::string lastWord = acceptor.call("getLastWord"); POTHOS_TEST_EQUAL(lastWord, "hello"); } + +// +// Test utility blocks +// + +static Pothos::Object performEval(const Pothos::Proxy& evaluator, const std::string& expr) +{ + auto periodicTrigger = Pothos::BlockRegistry::make("/blocks/periodic_trigger"); + periodicTrigger.call("setRate", 5); // Triggers per second + periodicTrigger.call("setArgs", std::vector{expr}); + + auto slotToMessage = Pothos::BlockRegistry::make( + "/blocks/slot_to_message", + "handleIt"); + auto collectorSink = Pothos::BlockRegistry::make( + "/blocks/collector_sink", + ""); // DType irrelevant + + { + Pothos::Topology topology; + + topology.connect(periodicTrigger, "triggered", evaluator, "setExpression"); + topology.connect(evaluator, "triggered", slotToMessage, "handleIt"); + topology.connect(slotToMessage, 0, collectorSink, 0); + + // Since periodic_trigger will trigger 5 times/second, half a second + // should be enough to get at least one. + topology.commit(); + Poco::Thread::sleep(500); // ms + } + + auto collectorSinkObjs = collectorSink.call("getMessages"); + POTHOS_TEST_FALSE(collectorSinkObjs.empty()); + + return collectorSinkObjs[0]; +} + +POTHOS_TEST_BLOCK("/proxy/python/tests", test_evaluator) +{ + constexpr auto jsonStr = "{\"outer\": [{\"inner\": [400,300,200,100]}, {\"inner\": [0.1,0.2,0.3,0.4]}]}"; + constexpr auto inner0Index = 3; + constexpr auto inner1Index = 2; + const auto localDoubles = std::vector{12.3456789, 0.987654321}; + const std::complex complexArg{1.351, 4.18}; + constexpr double doubleArg0 = 1234.0; + constexpr double doubleArg1 = 5678.0; + + auto cppJSON = json::parse(jsonStr); + + const auto expectedResult = (cppJSON["outer"][0]["inner"][inner0Index].get() + * cppJSON["outer"][1]["inner"][inner1Index].get()) + + std::pow((localDoubles[0] - std::pow(localDoubles[1] + std::abs(complexArg), 2)), 3) + - doubleArg0 + + doubleArg1; + + auto evaluator = Pothos::BlockRegistry::make( + "/python/evaluator", + std::vector{"inner0Index", "inner1Index", "complexArg", "doubleArg"}); + evaluator.call("setInner0Index", inner0Index); + evaluator.call("setInner1Index", inner1Index); + evaluator.call("setComplexArg", complexArg); + evaluator.call("setDoubleArg", doubleArg0, doubleArg1); + + auto imports = std::vector{"json","math","numpy"}; + evaluator.call("setImports", imports); + + auto env = Pothos::ProxyEnvironment::make("python"); + auto localVars = Pothos::ProxyMap + { + {env->makeProxy("testJSON"), env->makeProxy(jsonStr)}, + {env->makeProxy("localDoubles"), env->makeProxy(localDoubles)} + }; + evaluator.call("setLocalVars", localVars); + + const std::string jsonExpr0 = "json.loads(testJSON)['outer'][0]['inner'][inner0Index]"; + const std::string jsonExpr1 = "json.loads(testJSON)['outer'][1]['inner'][inner1Index]"; + const std::string powExpr = "numpy.power([localDoubles[0] - math.pow(localDoubles[1] + abs(complexArg), 2)], 3)[0]"; + const auto expr = jsonExpr0 + " * " + jsonExpr1 + " + " + powExpr + "- doubleArg0 + doubleArg1"; + + auto result = performEval(evaluator, expr); + POTHOS_TEST_CLOSE( + expectedResult, + result.convert(), + 1e-3); +}