From 8d53363a9968af47393f8af3615c011b35cac2fa Mon Sep 17 00:00:00 2001 From: rstein Date: Mon, 22 Jan 2024 15:59:53 +0100 Subject: [PATCH] UI integration tests/demo Signed-off-by: Ralph J. Steinhagen --- .../gnuradio-4.0/algorithm/ImChart.hpp | 6 +- .../gnuradio-4.0/basic/clock_source.hpp | 2 +- blocks/testing/CMakeLists.txt | 4 + .../gnuradio-4.0/testing/ImChartMonitor.hpp | 89 +++++++++++++ blocks/testing/test/CMakeLists.txt | 1 + blocks/testing/test/qa_UI_Integration.cpp | 120 ++++++++++++++++++ core/include/gnuradio-4.0/HistoryBuffer.hpp | 7 + 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 blocks/testing/include/gnuradio-4.0/testing/ImChartMonitor.hpp create mode 100644 blocks/testing/test/CMakeLists.txt create mode 100644 blocks/testing/test/qa_UI_Integration.cpp diff --git a/algorithm/include/gnuradio-4.0/algorithm/ImChart.hpp b/algorithm/include/gnuradio-4.0/algorithm/ImChart.hpp index 30887d01..ed2c1c66 100644 --- a/algorithm/include/gnuradio-4.0/algorithm/ImChart.hpp +++ b/algorithm/include/gnuradio-4.0/algorithm/ImChart.hpp @@ -319,8 +319,8 @@ struct ImChart { continue; } - const auto brailleColIndex = horAxisTransform::toScreen(x, axis_min_x, axis_max_x, horOffset, arrayWidth); - const auto brailleRowIndex = arrayHeight - verAxisTransform::toScreen(y, axis_min_y, axis_max_y, 0UZ, arrayHeight); + const auto brailleColIndex = horAxisTransform::toScreen(x, ValueType(axis_min_x), ValueType(axis_max_x), horOffset, arrayWidth); + const auto brailleRowIndex = arrayHeight - verAxisTransform::toScreen(y, ValueType(axis_min_y), ValueType(axis_max_y), 0UZ, arrayHeight); if (brailleRowIndex >= (arrayHeight - 1UZ) || brailleColIndex >= arrayWidth || (style == Style::Bars && brailleRowIndex >= horAxisPosY)) { continue; } @@ -443,7 +443,7 @@ struct ImChart { [[nodiscard]] constexpr std::size_t getVerticalAxisPositionX() const noexcept { - auto y_axis_x = std::is_same_v ? 0UZ : static_cast(((0. - axis_min_x) / (axis_max_x - axis_min_x)) * static_cast(_screen_width - 1UZ)); + auto y_axis_x = std::is_same_v ? 0UZ : static_cast((std::max(0. - axis_min_x, 0.) / (axis_max_x - axis_min_x)) * static_cast(_screen_width - 1UZ)); // adjust for axis labels std::size_t y_label_width = std::max(fmt::format("{:G}", axis_min_y).size(), fmt::format("{:G}", axis_max_y).size()); return std::clamp(y_axis_x, y_label_width + 3, _screen_width); // Ensure axis positions are within screen bounds diff --git a/blocks/basic/include/gnuradio-4.0/basic/clock_source.hpp b/blocks/basic/include/gnuradio-4.0/basic/clock_source.hpp index 42bcd0ad..d8c341a2 100644 --- a/blocks/basic/include/gnuradio-4.0/basic/clock_source.hpp +++ b/blocks/basic/include/gnuradio-4.0/basic/clock_source.hpp @@ -53,7 +53,7 @@ struct ClockSource : public gr::Block sample_rate = 1000.f; A> chunk_size = 100; std::shared_ptr userProvidedThread; - bool verbose_console = true; + bool verbose_console = false; private: std::chrono::time_point _beginSequenceTimePoint = ClockSourceType::now(); diff --git a/blocks/testing/CMakeLists.txt b/blocks/testing/CMakeLists.txt index 7bd52664..e4da2d3c 100644 --- a/blocks/testing/CMakeLists.txt +++ b/blocks/testing/CMakeLists.txt @@ -1,3 +1,7 @@ add_library(gr-testing INTERFACE) target_link_libraries(gr-testing INTERFACE gnuradio-core ut-benchmark) target_include_directories(gr-testing INTERFACE $ $) + +if (ENABLE_TESTING) + add_subdirectory(test) +endif () diff --git a/blocks/testing/include/gnuradio-4.0/testing/ImChartMonitor.hpp b/blocks/testing/include/gnuradio-4.0/testing/ImChartMonitor.hpp new file mode 100644 index 00000000..02ce49ce --- /dev/null +++ b/blocks/testing/include/gnuradio-4.0/testing/ImChartMonitor.hpp @@ -0,0 +1,89 @@ +#ifndef GNURADIO_IMCHARTMONITOR_HPP +#define GNURADIO_IMCHARTMONITOR_HPP + +#include + +#include +#include +#include + +#include +#include + +namespace gr::testing { + +template +struct ImChartMonitor : public Block, BlockingIO> { + using ClockSourceType = std::chrono::system_clock; + PortIn in; + float sample_rate = 1000.0f; + std::string signal_name = "unknown signal"; + + HistoryBuffer _historyBufferX{ 1000 }; + HistoryBuffer _historyBufferY{ 1000 }; + HistoryBuffer _historyBufferTags{ 1000 }; + + void + start() { + fmt::println("started sink {} aka. '{}'", this->unique_name, this->name); + in.max_samples = 10UZ; + } + + void + stop() { + fmt::println("stopped sink {} aka. '{}'", this->unique_name, this->name); + } + + constexpr void + processOne(const T &input) noexcept { + in.max_samples = 2 * sample_rate / 25; + const float Ts = 1.0f / sample_rate; + _historyBufferX.push_back(_historyBufferX[1] + Ts); + _historyBufferY.push_back(input); + + if (this->input_tags_present()) { // received tag + _historyBufferTags.push_back(this->mergedInputTag()); + _historyBufferTags[1].index = 0; + this->_mergedInputTag.map.clear(); // TODO: provide proper API for clearing tags + } else { + _historyBufferTags.push_back(Tag(-1, property_map())); + } + } + + work::Status + draw() noexcept { + [[maybe_unused]] const work::Status status = this->invokeWork(); // calls work(...) -> processOne(...) (all in the same thread as this 'draw()' + const auto [xMin, xMax] = std::ranges::minmax_element(_historyBufferX); + const auto [yMin, yMax] = std::ranges::minmax_element(_historyBufferY); + if (_historyBufferX.empty() || *xMin == *xMax || *yMin == *yMax) { + return status; // buffer or axes' ranges are empty -> skip drawing + } + fmt::println("\033[2J\033[H"); + // create reversed copies -- draw(...) expects std::ranges::input_range -> + // TODO: change draw routine and/or write wrapper and/or provide direction option to HistoryBuffer + std::vector reversedX(_historyBufferX.rbegin(), _historyBufferX.rend()); + std::vector reversedY(_historyBufferY.rbegin(), _historyBufferY.rend()); + std::vector reversedTag(_historyBufferX.size()); + std::transform(_historyBufferTags.rbegin(), _historyBufferTags.rend(), _historyBufferY.rbegin(), reversedTag.begin(), [](const Tag& tag, const T& yValue) { return tag.index < 0 ? T(0) : yValue; }); + + auto adjustRange = [](T min, T max) { + min = std::min(min, T(0)); + max = std::max(max, T(0)); + const T margin = (max - min) * 0.2; + return std::pair{min - margin, max + margin}; + }; + + auto chart = gr::graphs::ImChart<130, 28>({ { *xMin, *xMax }, adjustRange(*yMin, *yMax) }); + chart.draw(reversedX, reversedY, signal_name); + chart.draw(reversedX, reversedTag, "Tags"); + chart.draw(); + fmt::println("buffer has {} samples - status {:10} # graph range x = [{:2.2}, {:2.2}] y = [{:2.2}, {:2.2}]", _historyBufferX.size(), magic_enum::enum_name(status), *xMin, *xMax, *yMin, *yMax); + return status; + } +}; + +} // namespace gr::testing + +ENABLE_REFLECTION_FOR_TEMPLATE(gr::testing::ImChartMonitor, in, sample_rate, signal_name) + +#endif // GNURADIO_IMCHARTMONITOR_HPP diff --git a/blocks/testing/test/CMakeLists.txt b/blocks/testing/test/CMakeLists.txt new file mode 100644 index 00000000..2d067a25 --- /dev/null +++ b/blocks/testing/test/CMakeLists.txt @@ -0,0 +1 @@ +add_ut_test(qa_UI_Integration) diff --git a/blocks/testing/test/qa_UI_Integration.cpp b/blocks/testing/test/qa_UI_Integration.cpp new file mode 100644 index 00000000..ea747eea --- /dev/null +++ b/blocks/testing/test/qa_UI_Integration.cpp @@ -0,0 +1,120 @@ +#include "boost/ut.hpp" + +#include "gnuradio-4.0/Block.hpp" +#include "gnuradio-4.0/Graph.hpp" +#include "gnuradio-4.0/Scheduler.hpp" +#include "gnuradio-4.0/Tag.hpp" + +#include "gnuradio-4.0/algorithm/ImChart.hpp" +#include "gnuradio-4.0/basic/clock_source.hpp" +#include "gnuradio-4.0/basic/FunctionGenerator.hpp" +#include "gnuradio-4.0/testing/ImChartMonitor.hpp" +#include "gnuradio-4.0/testing/TagMonitors.hpp" + +#if defined(__clang__) && __clang_major__ >= 15 +// clang 16 does not like ut's default reporter_junit due to some issues with stream buffers and output redirection +template<> +auto boost::ut::cfg = boost::ut::runner>{}; +#endif + +const boost::ut::suite TagTests = [] { + using namespace boost::ut; + using namespace gr; + using namespace gr::basic; + using namespace gr::testing; + + "FunctionGenerator + ClockSource FAIR test"_test = [] { + using namespace function_generator; + constexpr std::uint64_t lengthSeconds = 10; + constexpr std::uint32_t N = 1000 * lengthSeconds; + constexpr float sample_rate = 1'000.f; + + Graph testGraph; + auto &clockSrc = testGraph.emplaceBlock>({ { "sample_rate", sample_rate }, { "n_samples_max", N }, { "name", "ClockSource" } }); + + auto addTimeTagEntry = [](gr::basic::ClockSource &clockSource, std::uint64_t timeInNanoseconds, const std::string &value) { + clockSource.tag_times.push_back(timeInNanoseconds); + clockSource.tag_values.push_back(value); + }; + + // all times are in nanoseconds + constexpr std::uint64_t ms = 1'000'000; // ms -> ns conversion factor (wish we had a proper C++ units-lib integration) + addTimeTagEntry(clockSrc, 10 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=1"); + addTimeTagEntry(clockSrc, 100 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=2"); + addTimeTagEntry(clockSrc, 300 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=3"); + addTimeTagEntry(clockSrc, 350 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=4"); + addTimeTagEntry(clockSrc, 550 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=5"); + addTimeTagEntry(clockSrc, 650 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=6"); + addTimeTagEntry(clockSrc, 800 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=7"); + addTimeTagEntry(clockSrc, 850 * ms, "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=8"); + clockSrc.repeat_period = 1000 * ms; + clockSrc.do_zero_order_hold = true; + auto &funcGen = testGraph.emplaceBlock>({ { "sample_rate", sample_rate }, { "name", "FunctionGenerator" } }); + funcGen.match_pred = [](const auto &tableEntry, const auto &searchEntry, const auto attempt) -> std::optional { + if (searchEntry.find("context") == searchEntry.end()) { + return std::nullopt; + } + if (tableEntry.find("context") == tableEntry.end()) { + return false; + } + + const pmtv::pmt searchEntryContext = searchEntry.at("context"); + const pmtv::pmt tableEntryContext = tableEntry.at("context"); + if (std::holds_alternative(searchEntryContext) && std::holds_alternative(tableEntryContext)) { + const auto searchString = std::get(searchEntryContext); + const auto tableString = std::get(tableEntryContext); + + if (!searchString.starts_with("CMD_BP_START:")) { + return std::nullopt; + } + + if (attempt >= searchString.length()) { + return std::nullopt; + } + auto [it1, it2] = std::ranges::mismatch(searchString, tableString); + if (std::distance(searchString.begin(), it1) == static_cast(searchString.length() - attempt)) { + return true; + } + } + return false; + }; + + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=1" } }, createConstPropertyMap(5.f)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=2" } }, createLinearRampPropertyMap(5.f, 30.f, 0.2f /* [s]*/)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=3" } }, createConstPropertyMap(30.f)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=4" } }, createParabolicRampPropertyMap(30.f, 20.f, .1f, 0.02f /* [s]*/)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=5" } }, createConstPropertyMap(20.f)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=6" } }, createCubicSplinePropertyMap(20.f, 10.f, 0.1f /* [s]*/)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=7" } }, createConstPropertyMap(10.f)); + funcGen.addFunctionTableEntry({ { "context", "CMD_BP_START:FAIR.SELECTOR.C=1:S=1:P=8" } }, createImpulseResponsePropertyMap(10.f, 20.f, 0.02f /* [s]*/, 0.06f /* [s]*/)); + + auto &sink = testGraph.emplaceBlock>({ { "name", "SampleGeneratorSink" } }); + expect(eq(ConnectionResult::SUCCESS, testGraph.connect<"out">(clockSrc).to<"in">(funcGen))); + expect(eq(ConnectionResult::SUCCESS, testGraph.connect<"out">(funcGen).to<"in">(sink))); + + // connect UI sink -- doesn't strictly need to be part of the graph due to BlockingIO definition + // but the present 'connect' API assumes it to be part of the Graph + auto &uiSink = testGraph.emplaceBlock>({ { "name", "BasicImChartSink" } }); + expect(eq(ConnectionResult::SUCCESS, testGraph.connect<"out">(funcGen).to<"in">(uiSink))); + + scheduler::Simple sched{ std::move(testGraph) }; + + std::thread uiLoop([&uiSink]() { + int i = 0; + fmt::println("start UI thread"); + while (uiSink.draw() != work::Status::DONE) { // mocks UI update loop with 25 Hz repetition + std::this_thread::sleep_for(std::chrono::milliseconds(40)); // 25 Hz <-> 40 ms period + } + fmt::println("asked to finish UI thread"); + std::this_thread::sleep_for(std::chrono::seconds(2)); // wait for another 2 seconds before closing down + fmt::println("finished UI thread"); + }); + sched.runAndWait(); + expect(eq(N, static_cast(sink.samples.size()))) << "Number of samples does not match"; + uiLoop.join(); + }; +}; + +int +main() { /* not needed for UT */ +} diff --git a/core/include/gnuradio-4.0/HistoryBuffer.hpp b/core/include/gnuradio-4.0/HistoryBuffer.hpp index 6915d771..3cd70a36 100644 --- a/core/include/gnuradio-4.0/HistoryBuffer.hpp +++ b/core/include/gnuradio-4.0/HistoryBuffer.hpp @@ -72,6 +72,8 @@ class HistoryBuffer { } public: + using value_type = T; + constexpr explicit HistoryBuffer() noexcept { static_assert(N != std::dynamic_extent, "need to specify capacity"); } constexpr explicit HistoryBuffer(std::size_t capacity) : _buffer(capacity * 2), _capacity(capacity) { @@ -153,6 +155,11 @@ class HistoryBuffer { return _size; } + [[nodiscard]] constexpr bool + empty() const noexcept { + return _size == 0; + } + [[nodiscard]] constexpr size_t capacity() const noexcept { return _capacity;