Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new ExprTK-based Expression evaluation blocks #486

Merged
merged 1 commit into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions blocks/math/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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 $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include/> $<INSTALL_INTERFACE:include/>)
target_link_libraries(gr-math INTERFACE gnuradio-core gnuradio-algorithm exprtk)
target_include_directories(gr-math INTERFACE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include/>
$<INSTALL_INTERFACE:include/>)

if (ENABLE_TESTING)
add_subdirectory(test)
endif ()
if(ENABLE_TESTING)
add_subdirectory(test)
endif()
352 changes: 352 additions & 0 deletions blocks/math/include/gnuradio-4.0/math/ExpressionBlocks.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
#ifndef EXPRESSIONBLOCKS_HPP
#define EXPRESSIONBLOCKS_HPP

#include <algorithm>
#include <gnuradio-4.0/Block.hpp>
#include <gnuradio-4.0/BlockRegistry.hpp>

#include <exprtk.hpp>

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<unsigned int>(i), //
static_cast<unsigned int>(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<std::byte*>(base_ptr);
auto end = static_cast<std::byte*>(end_ptr);
auto access = static_cast<std::byte*>(access_ptr);

if (end < base) {
throw std::out_of_range(fmt::format("invalid vector boundaries [{}, {}]", base_ptr, end_ptr));
}

return {static_cast<std::size_t>(end - base) / elementSize, (access - base) / static_cast<ssize_t>(elementSize)};
}

struct vector_access_rtc : public exprtk::vector_access_runtime_check {
std::unordered_map<void*, std::string> vector_map;

bool handle_runtime_violation(violation_context& context) override {
auto itr = vector_map.find(static_cast<void*>(context.base_ptr));
const std::string& vector_name = (itr != vector_map.end()) ? itr->second : "Unknown";

const auto typeSize = static_cast<std::size_t>(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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code due to the throw directly above.
For custom state handling logic, I see the argument of having such a "never reached" statement, but here we effectively guard against something the compiler guarantees.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless exceptions are disabled...

}
};

} // namespace detail

template<typename T>
requires std::floating_point<T>
struct ExpressionSISO : Block<ExpressionSISO<T>> {
using Description = Doc<R""(@brief Single-Input-Single-Output (SISO) expression evaluator.

This block uses ExprTK to compute a user-defined expression for each input sample.
The input sample is referenced by the variable `x`, and the output is produced as the evaluated expression.

Examples:
- `y := a * x + b` // (simple linear scaling)
- `a * x + b` // (as above, the 'y:=' is optional)
- `y := sin(pi * x)` // (non-linear transformation)
- `y := y + 0.1*x` // (recursive IIR-like update using `y` as state)
- `y := clamp(-1.0, x, 1.0)` // (clamping the input range)

For full syntax, conditionals, loops, and advanced features:
@see https://www.partow.net/programming/exprtk/index.html
@see https://github.com/ArashPartow/exprtk
)"">;
template<typename U, fixed_string description = "", typename... Arguments>
using A = Annotated<U, description, Arguments...>;

PortIn<T> in;
PortOut<T> out;

A<std::string, "expr string", Doc<"for syntax see: https://github.com/ArashPartow/exprtk">> expr_string = "clamp(-1.0, sin(2 * pi * x) + cos(x / 2 * pi), +1.0)";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use something more trivial as the default expression, maybe:

Suggested change
A<std::string, "expr string", Doc<"for syntax see: https://github.com/ArashPartow/exprtk">> expr_string = "clamp(-1.0, sin(2 * pi * x) + cos(x / 2 * pi), +1.0)";
A<std::string, "expr string", Doc<"for syntax see: https://github.com/ArashPartow/exprtk">> expr_string = "0";

Just in case the initialization fails, no signal is easier to diagnose than getting some arbitrary signal. We have examples in the docstring and unittests.

A<T, "a", Doc<"free parameter 'a' for use in expressions">, Visible> param_a = T(1.0);
A<T, "b", Doc<"free parameter 'b' for use in expressions">, Visible> param_b = T(0.0);
A<T, "c", Doc<"free parameter 'c' for use in expressions">, Visible> param_c = T(0.0);
Comment on lines +91 to +93
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider changing this so all 3 parameters have the same initial value.


GR_MAKE_REFLECTABLE(ExpressionSISO, in, out, expr_string, param_a, param_b, param_c);

exprtk::symbol_table<T> _symbol_table{};
exprtk::expression<T> _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<T> 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<ExpressionSISO<float>, property_map>, "Block type ExpressionSISO must be constructible from property_map");

template<typename T>
requires std::floating_point<T>
struct ExpressionDISO : Block<ExpressionDISO<T>> {
using Description = Doc<R""(@brief Dual-Input-Single-Output (DISO) expression evaluator.

This block uses ExprTK to compute a user-defined expression from two input samples.
The two input samples are referenced by variables `x` and `y`, and the output is `z` (the evaluated expression).

Examples:
- `z := a * (x + y)` // (combining two inputs linearly)
- `a * (x + y)` // (as above, the 'y:=' is optional)
- `z := sin(x) * cos(y)` // (more complex trigonometric transformations)
- `z := z + (x - y)` // (recursive usage: `z` can store state)
- `z := inrange(-1, x+y, 1) ? (x+y) : 0` // (conditional logic)

For full syntax, conditionals, loops, and advanced features:
@see https://www.partow.net/programming/exprtk/index.html
@see https://github.com/ArashPartow/exprtk
)"">;
template<typename U, fixed_string description = "", typename... Arguments>
using A = Annotated<U, description, Arguments...>;

PortIn<T> in0;
PortIn<T> in1;
PortOut<T> out;

A<std::string, "expr string", Doc<"for syntax see: https://github.com/ArashPartow/exprtk">> expr_string = "a*(x+y)";
A<T, "a", Doc<"free parameter 'a' for use in expressions">, Visible> param_a = T(1.0);
A<T, "b", Doc<"free parameter 'b' for use in expressions">, Visible> param_b = T(0.0);
A<T, "c", Doc<"free parameter 'c' for use in expressions">, Visible> param_c = T(0.0);

GR_MAKE_REFLECTABLE(ExpressionDISO, in0, in1, out, expr_string, param_a, param_b, param_c);

exprtk::symbol_table<T> _symbol_table{};
exprtk::expression<T> _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<T> 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<ExpressionDISO<float>, property_map>, "Block type ExpressionDISO must be constructible from property_map");

template<typename T>
requires std::floating_point<T>
struct ExpressionBulk : Block<ExpressionBulk<T>> {
using Description = Doc<R""(@@brief Bulk array expression evaluator.

This block uses ExprTK to process arrays of input samples (`vecIn`) and produce arrays of output samples (`vecOut`) per work call.
The user-defined expression can manipulate entire arrays at once.

For example:
- `vecOut := a * vecIn;` (simple scaling of all input samples)
- `for (i,0,vecIn.size()) vecOut[i] := vecIn[i] + c;` (element-wise operations)
- `vecOut := vecOut + a * vecIn;` (recursive updates across consecutive calls)

Complex operations (e.g., loops, conditions, indexing) are supported by ExprTK.
For full syntax, conditionals, loops, and advanced features:
@see https://www.partow.net/programming/exprtk/index.html
@see https://github.com/ArashPartow/exprtk
)"">;
template<typename U, fixed_string description = "", typename... Arguments>
using A = Annotated<U, description, Arguments...>;

PortIn<T> in;
PortOut<T> out;

A<std::string, "expr string", Doc<"for syntax see: https://github.com/ArashPartow/exprtk">> expr_string = "vecOut := a * vecIn;";
A<T, "a", Doc<"free parameter 'a' for use in expressions">, Visible> param_a = T(1.0);
A<T, "b", Doc<"free parameter 'b' for use in expressions">, Visible> param_b = T(0.0);
A<T, "c", Doc<"free parameter 'c' for use in expressions">, Visible> param_c = T(0.0);
A<bool, "runtime_checks", Doc<"e.g. vector index range checks etc.">, 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<T, 1UZ> _arrOutDummy{T(0)}; // only needed for initialising
static constexpr std::size_t _maxBaseSize = 1UZ << 16;
exprtk::vector_view<T> _vecIn = exprtk::make_vector_view<T>(_arrOutDummy.data(), _maxBaseSize);
exprtk::vector_view<T> _vecOut = exprtk::make_vector_view<T>(_arrOutDummy.data(), _maxBaseSize);

std::vector<T> _vecInData{};
std::vector<T> _vecOutData{};
detail::vector_access_rtc _vec_rtc{};
exprtk::symbol_table<T> _symbol_table{};
exprtk::expression<T> _expression{};

void initExpression(std::source_location location = std::source_location::current()) {
_expression = exprtk::expression<T>();
_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<T> 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<decltype(inputSpan.begin())>;
std::ranges::copy_n(inputSpan.begin(), static_cast<PtrDiff_t>(_vecInData.size()), _vecInData.begin());
_expression.value(); // evaluate expression, exception handled by caller
std::ranges::copy_n(_vecOutData.begin(), static_cast<PtrDiff_t>(_vecOutData.size()), outputSpan.begin());

std::ignore = inputSpan.consume(_vecInData.size());
outputSpan.publish(_vecOutData.size());
return work::Status::OK;
}
};
static_assert(std::is_constructible_v<ExpressionBulk<float>, property_map>, "Block type ExpressionBulk must be constructible from property_map");

} // namespace gr::blocks::math

const inline static auto registerConstMath = gr::registerBlock<gr::blocks::math::ExpressionSISO, float, double>(gr::globalBlockRegistry()) //
+ gr::registerBlock<gr::blocks::math::ExpressionDISO, float, double>(gr::globalBlockRegistry()) //
+ gr::registerBlock<gr::blocks::math::ExpressionBulk, float, double>(gr::globalBlockRegistry());

#endif // EXPRESSIONBLOCKS_HPP
1 change: 1 addition & 0 deletions blocks/math/test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
add_ut_test(qa_Math)
add_ut_test(qa_ExpressionBlocks)
Loading
Loading