From 389d30bfa77fad8a22d9468bd8b60c464f1d1c26 Mon Sep 17 00:00:00 2001 From: rstein Date: Mon, 9 Dec 2024 16:39:36 +0100 Subject: [PATCH] added new ExprTK-based Expression evaluation blocks * ExpressionSISO: Single-Input-Single-Output * ExpressionDISO: Dual -Input-Single-Output * ExpressionBulk -> std:span-Input-to-std::span-Output. Signed-off-by: rstein Signed-off-by: Ralph J. Steinhagen Signed-off-by: Alexander Krimm --- blocks/math/CMakeLists.txt | 11 +- .../gnuradio-4.0/math/ExpressionBlocks.hpp | 352 ++++++++++++++++++ blocks/math/test/CMakeLists.txt | 1 + blocks/math/test/qa_ExpressionBlocks.cpp | 105 ++++++ blocks/math/test/qa_Math.cpp | 2 +- .../gnuradio-4.0/testing/NullSources.hpp | 20 +- 6 files changed, 476 insertions(+), 15 deletions(-) create mode 100644 blocks/math/include/gnuradio-4.0/math/ExpressionBlocks.hpp create mode 100644 blocks/math/test/qa_ExpressionBlocks.cpp diff --git a/blocks/math/CMakeLists.txt b/blocks/math/CMakeLists.txt index 643e822d..f2bfeee0 100644 --- a/blocks/math/CMakeLists.txt +++ b/blocks/math/CMakeLists.txt @@ -1,7 +1,8 @@ add_library(gr-math INTERFACE) -target_link_libraries(gr-math INTERFACE gnuradio-core gnuradio-algorithm) -target_include_directories(gr-math INTERFACE $ $) +target_link_libraries(gr-math INTERFACE gnuradio-core gnuradio-algorithm exprtk) +target_include_directories(gr-math INTERFACE $ + $) -if (ENABLE_TESTING) - add_subdirectory(test) -endif () +if(ENABLE_TESTING) + add_subdirectory(test) +endif() diff --git a/blocks/math/include/gnuradio-4.0/math/ExpressionBlocks.hpp b/blocks/math/include/gnuradio-4.0/math/ExpressionBlocks.hpp new file mode 100644 index 00000000..cc9e9eb9 --- /dev/null +++ b/blocks/math/include/gnuradio-4.0/math/ExpressionBlocks.hpp @@ -0,0 +1,352 @@ +#ifndef EXPRESSIONBLOCKS_HPP +#define EXPRESSIONBLOCKS_HPP + +#include +#include +#include + +#include + +namespace gr::blocks::math { + +namespace detail { + +inline std::string formatParserError(const auto& parser, std::string_view expression) { + std::stringstream ss; + for (std::size_t i = 0; i < parser.error_count(); ++i) { + const auto error = parser.get_error(i); + + ss << fmt::format("ExprTk Parser Error({:2}): Position: {:2}\nType: [{:14}] Msg: {}; expression:\n{}\n", // + static_cast(i), // + static_cast(error.token.position), // + exprtk::parser_error::to_str(error.mode), error.diagnostic, expression); + } + return ss.str(); +} + +struct VectorInfo { + std::size_t size; + ssize_t index; +}; + +inline VectorInfo computeVectorInfo(void* base_ptr, void* end_ptr, std::size_t elementSize, void* access_ptr) { + if (!base_ptr || !end_ptr || !access_ptr) { + throw std::invalid_argument("null pointer(s) provided."); + } + + auto base = static_cast(base_ptr); + auto end = static_cast(end_ptr); + auto access = static_cast(access_ptr); + + if (end < base) { + throw std::out_of_range(fmt::format("invalid vector boundaries [{}, {}]", base_ptr, end_ptr)); + } + + return {static_cast(end - base) / elementSize, (access - base) / static_cast(elementSize)}; +} + +struct vector_access_rtc : public exprtk::vector_access_runtime_check { + std::unordered_map vector_map; + + bool handle_runtime_violation(violation_context& context) override { + auto itr = vector_map.find(static_cast(context.base_ptr)); + const std::string& vector_name = (itr != vector_map.end()) ? itr->second : "Unknown"; + + const auto typeSize = static_cast(context.type_size); + auto [vecSize, vecIndex] = computeVectorInfo(context.base_ptr, context.end_ptr, typeSize, context.access_ptr); + throw gr::exception(fmt::format("vector access '{name}[{index}]' outside of [0, {size}[ (typesize: {typesize})", // + fmt::arg("name", vector_name), fmt::arg("size", vecSize), fmt::arg("index", vecIndex), fmt::arg("typesize", typeSize))); + return false; // should never reach here + } +}; + +} // namespace detail + +template +requires std::floating_point +struct ExpressionSISO : Block> { + using Description = Doc, Visible> param_a = T(1.0); + A, Visible> param_b = T(0.0); + A, Visible> param_c = T(0.0); + + GR_MAKE_REFLECTABLE(ExpressionSISO, in, out, expr_string, param_a, param_b, param_c); + + exprtk::symbol_table _symbol_table{}; + exprtk::expression _expression{}; + T _in; + T _out; + + void initExpression(std::source_location location = std::source_location::current()) { + reset(); + _symbol_table.clear(); + _symbol_table.add_variable("x", _in); + _symbol_table.add_variable("y", _out); + + _symbol_table.add_variable(std::string(param_a.description()), param_a.value); + _symbol_table.add_variable(std::string(param_b.description()), param_b.value); + _symbol_table.add_variable(std::string(param_c.description()), param_c.value); + + _symbol_table.add_constants(); + _expression.register_symbol_table(_symbol_table); + + if (exprtk::parser parser; !parser.compile(expr_string, _expression)) { + throw gr::exception(detail::formatParserError(parser, expr_string), location); + } + } + + void settingsChanged(const property_map& /*oldSettings*/, const property_map& newSettings) { + if (newSettings.contains("expr_string")) { + initExpression(); + } + } + + void start() { initExpression(); } + + void reset() { + _in = T(0); + _out = T(0); + } + + [[nodiscard]] constexpr T processOne(T input) { + _in = input; + _out = _expression.value(); // evaluate expression, _out == 'y' defined to allow for recursion + return _out; + } +}; +static_assert(std::is_constructible_v, property_map>, "Block type ExpressionSISO must be constructible from property_map"); + +template +requires std::floating_point +struct ExpressionDISO : Block> { + using Description = Doc, Visible> param_a = T(1.0); + A, Visible> param_b = T(0.0); + A, Visible> param_c = T(0.0); + + GR_MAKE_REFLECTABLE(ExpressionDISO, in0, in1, out, expr_string, param_a, param_b, param_c); + + exprtk::symbol_table _symbol_table{}; + exprtk::expression _expression{}; + T _in0; + T _in1; + T _out; + + void initExpression(std::source_location location = std::source_location::current()) { + reset(); + _symbol_table.clear(); + _symbol_table.add_variable("x", _in0); + _symbol_table.add_variable("y", _in1); + _symbol_table.add_variable("z", _out); + + _symbol_table.add_variable(std::string(param_a.description()), param_a.value); + _symbol_table.add_variable(std::string(param_b.description()), param_b.value); + _symbol_table.add_variable(std::string(param_c.description()), param_c.value); + + _symbol_table.add_constants(); + _expression.register_symbol_table(_symbol_table); + + if (exprtk::parser parser; !parser.compile(expr_string, _expression)) { + throw gr::exception(detail::formatParserError(parser, expr_string), location); + } + } + + void settingsChanged(const property_map& /*oldSettings*/, const property_map& newSettings) { + if (newSettings.contains("expr_string")) { + initExpression(); + } + } + + void start() { initExpression(); }; + + void reset() { + _in0 = T(0); + _in1 = T(0); + _out = T(0); + } + + [[nodiscard]] constexpr T processOne(T input0, T input1) { + _in0 = input0; + _in1 = input1; + _out = _expression.value(); // evaluate expression, _out == 'y' defined to allow for recursion + return _out; + } +}; +static_assert(std::is_constructible_v, property_map>, "Block type ExpressionDISO must be constructible from property_map"); + +template +requires std::floating_point +struct ExpressionBulk : Block> { + using Description = Doc; + template + using A = Annotated; + + PortIn in; + PortOut out; + + A> expr_string = "vecOut := a * vecIn;"; + A, Visible> param_a = T(1.0); + A, Visible> param_b = T(0.0); + A, Visible> param_c = T(0.0); + A, Visible> runtime_checks = true; + + GR_MAKE_REFLECTABLE(ExpressionBulk, in, out, expr_string, param_a, param_b, param_c, runtime_checks); + + // vector_views that reference _vecInData and _vecOutData + // will be registered once and then just rebased as needed. + // N.B. _maxBaseSize limits the maximum chunk size and needs + // to be defined in-advance due to ExprTk constraints + std::array _arrOutDummy{T(0)}; // only needed for initialising + static constexpr std::size_t _maxBaseSize = 1UZ << 16; + exprtk::vector_view _vecIn = exprtk::make_vector_view(_arrOutDummy.data(), _maxBaseSize); + exprtk::vector_view _vecOut = exprtk::make_vector_view(_arrOutDummy.data(), _maxBaseSize); + + std::vector _vecInData{}; + std::vector _vecOutData{}; + detail::vector_access_rtc _vec_rtc{}; + exprtk::symbol_table _symbol_table{}; + exprtk::expression _expression{}; + + void initExpression(std::source_location location = std::source_location::current()) { + _expression = exprtk::expression(); + _symbol_table.clear(); + + if (_vecInData.empty() || _vecIn.data() == _arrOutDummy.data()) { + _vecInData.resize(1UZ); + } + if (_vecOutData.empty() || _vecOut.data() == _arrOutDummy.data()) { + _vecOutData.resize(1UZ); + } + + // rebase vector views to current data + _vecIn.rebase(_vecInData.data()); + _vecIn.set_size(_vecInData.size()); + _vecOut.rebase(_vecOutData.data()); + _vecOut.set_size(_vecOutData.size()); + + _symbol_table.add_vector("vecIn", _vecIn); + _symbol_table.add_vector("vecOut", _vecOut); + + _symbol_table.add_variable(std::string(param_a.description()), param_a.value); + _symbol_table.add_variable(std::string(param_b.description()), param_b.value); + _symbol_table.add_variable(std::string(param_c.description()), param_c.value); + + _symbol_table.add_constants(); + _expression.register_symbol_table(_symbol_table); + + exprtk::parser parser; + if (runtime_checks) { + _vec_rtc.vector_map[_vecIn.data()] = "vecIn"; + _vec_rtc.vector_map[_vecOut.data()] = "vecOut"; + parser.register_vector_access_runtime_check(_vec_rtc); + } + + if (!parser.compile(expr_string, _expression)) { + throw gr::exception(detail::formatParserError(parser, expr_string), location); + } + } + + void settingsChanged(const gr::property_map& /*oldSettings*/, const gr::property_map& newSettings) { + if (newSettings.contains("expr_string")) { + initExpression(); + } + } + + void start() { initExpression(); } + + work::Status processBulk(InputSpanLike auto& inputSpan, OutputSpanLike auto& outputSpan) { + if (inputSpan.size() != _vecInData.size() || outputSpan.size() != _vecOutData.size()) { + _vecInData.resize(std::min(inputSpan.size(), _maxBaseSize)); + _vecOutData.resize(std::min(outputSpan.size(), _maxBaseSize)); + + // rebase vector views to new internal memory storage of backing buffer vectors + _vecIn.rebase(_vecInData.data()); + _vecIn.set_size(_vecInData.size()); + _vecOut.rebase(_vecOutData.data()); + _vecOut.set_size(_vecOutData.size()); + + if (runtime_checks) { + _vec_rtc.vector_map.clear(); + _vec_rtc.vector_map[_vecIn.data()] = "vecIn"; + _vec_rtc.vector_map[_vecOut.data()] = "vecOut"; + } + } + + using PtrDiff_t = std::iter_difference_t; + std::ranges::copy_n(inputSpan.begin(), static_cast(_vecInData.size()), _vecInData.begin()); + _expression.value(); // evaluate expression, exception handled by caller + std::ranges::copy_n(_vecOutData.begin(), static_cast(_vecOutData.size()), outputSpan.begin()); + + std::ignore = inputSpan.consume(_vecInData.size()); + outputSpan.publish(_vecOutData.size()); + return work::Status::OK; + } +}; +static_assert(std::is_constructible_v, property_map>, "Block type ExpressionBulk must be constructible from property_map"); + +} // namespace gr::blocks::math + +const inline static auto registerConstMath = gr::registerBlock(gr::globalBlockRegistry()) // + + gr::registerBlock(gr::globalBlockRegistry()) // + + gr::registerBlock(gr::globalBlockRegistry()); + +#endif // EXPRESSIONBLOCKS_HPP diff --git a/blocks/math/test/CMakeLists.txt b/blocks/math/test/CMakeLists.txt index 29187d87..fdb63354 100644 --- a/blocks/math/test/CMakeLists.txt +++ b/blocks/math/test/CMakeLists.txt @@ -1 +1,2 @@ add_ut_test(qa_Math) +add_ut_test(qa_ExpressionBlocks) diff --git a/blocks/math/test/qa_ExpressionBlocks.cpp b/blocks/math/test/qa_ExpressionBlocks.cpp new file mode 100644 index 00000000..45408782 --- /dev/null +++ b/blocks/math/test/qa_ExpressionBlocks.cpp @@ -0,0 +1,105 @@ +#include + +#include + +#include +#include +#include +#include + +const boost::ut::suite<"basic expression block tests"> basicMath = [] { + using namespace boost::ut; + using namespace gr; + using namespace gr::blocks::math; + using testing::ProcessFunction::USE_PROCESS_ONE; + + "ExpressionSISO"_test = [](const T&) { + Graph graph; + + auto& source = graph.emplaceBlock>({{"n_samples_max", 10U}, {"default_value", T(21)}}); + auto& exprBlock = graph.emplaceBlock>({{"expr_string", "a*x"}, {"param_a", T(2)}}); + auto& tagSink = graph.emplaceBlock>({{"log_tags", true}, {"log_samples", true}}); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(source).template to<"in">(exprBlock))); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(exprBlock).template to<"in">(tagSink))); + + auto sched = gr::scheduler::Simple<>(std::move(graph)); + expect(sched.runAndWait().has_value()); + + expect(approx(source.default_value, T(21), T(1e-3f))); + expect(approx(exprBlock.param_a.value, T(2), T(1e-3f))); + expect(eq(tagSink._samples.size(), source.n_samples_max)); + expect(approx(tagSink._samples[0], T(42), T(1e-3f))); + } | std::tuple{}; + + "ExpressionDISO"_test = [](const T&) { + Graph graph; + + auto& source1 = graph.emplaceBlock>({{"n_samples_max", 10U}, {"default_value", T(7)}}); + auto& source2 = graph.emplaceBlock>({{"n_samples_max", 10U}, {"default_value", T(5)}}); + auto& exprBlock = graph.emplaceBlock>({{"expr_string", "z := a * (x + y + 2)"}, {"param_a", T(3)}}); + auto& tagSink = graph.emplaceBlock>({{"log_tags", true}, {"log_samples", true}}); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(source1).template to<"in0">(exprBlock))); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(source2).template to<"in1">(exprBlock))); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(exprBlock).template to<"in">(tagSink))); + + auto sched = gr::scheduler::Simple<>(std::move(graph)); + expect(sched.runAndWait().has_value()); + + expect(approx(source1.default_value, T(7), T(1e-3f))); + expect(approx(source2.default_value, T(5), T(1e-3f))); + expect(approx(exprBlock.param_a.value, T(3), T(1e-3f))); + expect(eq(tagSink._samples.size(), source1.n_samples_max)); + expect(eq(tagSink._samples.size(), source2.n_samples_max)); + expect(approx(tagSink._samples[0], T(42), T(1e-3f))); + } | std::tuple{}; + + "ExpressionBulk"_test = [](const T&) { + Graph graph; + + auto& source = graph.emplaceBlock>({{"n_samples_max", 10U}}); + auto& exprBlock = graph.emplaceBlock>({{"expr_string", "vecOut := a * vecIn"}, {"param_a", T(2)}}); + auto& tagSink = graph.emplaceBlock>({{"log_samples", true}}); + + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(source).template to<"in">(exprBlock))); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(exprBlock).template to<"in">(tagSink))); + + auto sched = gr::scheduler::Simple<>(std::move(graph)); + expect(sched.runAndWait().has_value()); + + expect(eq(tagSink._samples.size(), source.n_samples_max)); + expect(approx(exprBlock.param_a.value, T(2), T(1e-3f))); + for (std::size_t i = 0; i < tagSink._samples.size(); ++i) { + T expected = T(1 + i) * T(2); + expect(approx(tagSink._samples[i], expected, T(1e-6))) << fmt::format("should be: output[{}] ({}) == {} * input[{}] ({}) ", i, tagSink._samples[i], exprBlock.param_a, i, expected); + } + } | std::tuple{}; + + "ExpressionBulk - exceptions"_test = [](const bool enableERuntimeChecks) { + Graph graph; + + auto& source = graph.emplaceBlock>({{"n_samples_max", 10U}}); + const std::string exprStr = R""(for (var i := 0; i < 100; i += 1) { + vecOut[i] := vecIn[i+1]; +} +)""; + auto& exprBlock = graph.emplaceBlock>({{"expr_string", exprStr}, {"runtime_checks", enableERuntimeChecks}}); + auto& tagSink = graph.emplaceBlock>({{"log_samples", true}}); + + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(source).template to<"in">(exprBlock))); + expect(eq(gr::ConnectionResult::SUCCESS, graph.connect<"out">(exprBlock).template to<"in">(tagSink))); + + auto sched = gr::scheduler::Simple<>(std::move(graph)); + + try { + expect(sched.runAndWait().has_value()); + expect(false) << fmt::format("should have failed"); + } catch (const gr::exception& ex) { + expect(true); + fmt::println("failed correctly with:\n{}\n", ex); + } catch (...) { + expect(false) << fmt::format("caught unknown/unexpected exception"); + } + } | std::vector{true /*, false -- disabled on purpose as this would trigger correctly trigger the ASAN checks*/}; +}; + +int main() { /* not needed for UT */ } diff --git a/blocks/math/test/qa_Math.cpp b/blocks/math/test/qa_Math.cpp index 68ac0fcd..f0158fa1 100644 --- a/blocks/math/test/qa_Math.cpp +++ b/blocks/math/test/qa_Math.cpp @@ -144,4 +144,4 @@ std::complex, std::complex*/>(); } | kArithmeticTypes; }; -int main() { /* not needed for UT */ } \ No newline at end of file +int main() { /* not needed for UT */ } diff --git a/blocks/testing/include/gnuradio-4.0/testing/NullSources.hpp b/blocks/testing/include/gnuradio-4.0/testing/NullSources.hpp index 1feef960..af73b3f7 100644 --- a/blocks/testing/include/gnuradio-4.0/testing/NullSources.hpp +++ b/blocks/testing/include/gnuradio-4.0/testing/NullSources.hpp @@ -25,17 +25,18 @@ static_assert(gr::BlockLike>); template struct ConstantSource : public gr::Block> { + using value_t = std::conditional_t, std::string, meta::fundamental_base_value_type_t>; // use base-type for types not supported by settings using Description = Doc; gr::PortOut out; - Annotated> default_value{}; + Annotated> default_value{}; Annotatedn_samples_max -> signal DONE (0: infinite)">> n_samples_max = 0U; Annotated> count = 0U; - GR_MAKE_REFLECTABLE(ConstantSource, out, n_samples_max, count); + GR_MAKE_REFLECTABLE(ConstantSource, out, default_value, n_samples_max, count); void reset() { count = 0U; } @@ -44,7 +45,7 @@ Commonly used for testing and simulations where consistent output and finite exe if (n_samples_max > 0 && count >= n_samples_max) { this->requestStop(); } - return default_value; + return T(default_value.value); } }; @@ -52,11 +53,12 @@ static_assert(gr::BlockLike>); template struct SlowSource : public gr::Block> { + using value_t = std::conditional_t, std::string, meta::fundamental_base_value_type_t>; // use base-type for types not supported by settings using Description = Doc; - gr::PortOut out; - Annotated> default_value{}; - Annotated> n_delay = 100U; + gr::PortOut out; + Annotated> default_value{}; + Annotated> n_delay = 100U; GR_MAKE_REFLECTABLE(SlowSource, out, n_delay); @@ -66,7 +68,7 @@ struct SlowSource : public gr::Block> { if (!lastEventAt || std::chrono::system_clock::now() - *lastEventAt > std::chrono::milliseconds(n_delay)) { lastEventAt = std::chrono::system_clock::now(); - output[0] = default_value; + output[0] = T(default_value.value); output.publish(1UZ); } else { output.publish(0UZ); @@ -91,7 +93,7 @@ Commonly used for testing and simulations where consistent output and finite exe Annotatedn_samples_max -> signal DONE (0: infinite)">> n_samples_max = 0U; Annotated> count = 0U; - GR_MAKE_REFLECTABLE(CountingSource, out, n_samples_max, count); + GR_MAKE_REFLECTABLE(CountingSource, out, default_value, n_samples_max, count); void reset() { count = 0U; } @@ -100,7 +102,7 @@ Commonly used for testing and simulations where consistent output and finite exe if (n_samples_max > 0 && count >= n_samples_max) { this->requestStop(); } - return default_value + T(count); + return T(default_value.value) + T(count); } };