Skip to content

Commit

Permalink
added Trigger Block
Browse files Browse the repository at this point in the history
implements a SchmittTrigger block with edge detection and optional interpolation

Signed-off-by: rstein <[email protected]>
Signed-off-by: Ralph J. Steinhagen <[email protected]>
  • Loading branch information
RalphSteinhagen committed Dec 3, 2024
1 parent d1c867b commit 0043518
Show file tree
Hide file tree
Showing 3 changed files with 288 additions and 4 deletions.
157 changes: 157 additions & 0 deletions blocks/basic/include/gnuradio-4.0/basic/Trigger.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#ifndef TRIGGER_HPP
#define TRIGGER_HPP

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

namespace gr::blocks::basic {

template<typename T, gr::trigger::InterpolationMethod Method>
requires(std::is_arithmetic_v<T> or (UncertainValueLike<T> && std::is_arithmetic_v<meta::fundamental_base_value_type_t<T>>))
struct SchmittTrigger : public gr::Block<SchmittTrigger<T, Method>> {
using Description = Doc<R""(@brief Digital Schmitt trigger implementation with optional intersample interpolation
@see https://en.wikipedia.org/wiki/Schmitt_trigger
The following sub-sample interpolation methods are supported:
* NO_INTERPOLATION: nomen est omen
* BASIC_LINEAR_INTERPOLATION: basic linear interpolation based on the new and previous sample
* LINEAR_INTERPOLATION: interpolation via linear regression over the samples between when
the lower and upper threshold has been crossed and vice versa
* POLYNOMIAL_INTERPOLATION: Savitzky–Golay filter-based methods
https://en.wikipedia.org/wiki/Savitzky%E2%80%93Golay_filter
(TODO: WIP needs Tensor<T> and SVD implementation)
The block generates the following optional trigger that are controlled by:
* trigger_name_rising_edge -> trigger name that is used when a rising edge is detected
* trigger_name_falling_edge -> trigger name that is used when a falling edge is detected
The trigger time and offset is calculated through sample counting from the last synchronising trigger.
The information is stored (info only) in `trigger_name`, `trigger_time`, `trigger_offset`.
)"">;
using enum gr::trigger::EdgeDetection;
using ClockSourceType = std::chrono::system_clock;
using value_t = meta::fundamental_base_value_type_t<T>;

template<typename U, gr::meta::fixed_string description = "", typename... Arguments>
using A = gr::Annotated<U, description, Arguments...>;

constexpr static std::size_t N_HISTORY = 16UZ;

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

A<value_t, "offset", Doc<"trigger offset">, Visible> offset{value_t(0)};
A<value_t, "threshold", Doc<"trigger threshold">, Visible> threshold{value_t(1)};
A<std::string, "rising trigger", Doc<"trigger name generated on detected rising edge (N.B. \"\" omits trigger)">, Visible> trigger_name_rising_edge{magic_enum::enum_name(RISING)};
A<std::string, "falling trigger", Doc<"trigger name generated on detected falling edge (N.B. \"\" omits trigger)">, Visible> trigger_name_falling_edge{magic_enum::enum_name(FALLING)};
A<float, "avg. sample rate", Visible> sample_rate = 1.f;

A<std::string, "trigger name", Doc<"last trigger used to synchronise time">> trigger_name = "";
A<std::uint64_t, "trigger time", Doc<"last trigger UTC time used for synchronisation (then sample counting)">, Unit<"ns">> trigger_time{0U};
A<float, "trigger offset", Doc<"last trigger offset time used for synchronisation (then sample counting)">, Unit<"s">> trigger_offset{0.0f};
std::string context = "";

GR_MAKE_REFLECTABLE(SchmittTrigger, in, out, offset, threshold, trigger_name_rising_edge, trigger_name_falling_edge, sample_rate, trigger_name, trigger_time, trigger_offset, context);

gr::trigger::SchmittTrigger<T, Method, N_HISTORY> _trigger{0, 1};
std::uint64_t _period{1U};
std::uint64_t _now{0U}; // TODO: init with wall-clock time and/or trigger_time (if one arrives)

void settingsChanged(const gr::property_map& /*oldSettings*/, const gr::property_map& newSettings) {
if (newSettings.contains("sample_rate")) {
_period = static_cast<std::uint64_t>(1e6f / sample_rate);
}
if (newSettings.contains("trigger_time")) {
_now = trigger_time + static_cast<std::uint64_t>(1e6f * trigger_offset);
}

if (newSettings.contains("offset") || newSettings.contains("threshold")) {
_trigger.setOffset(offset);
_trigger.setThreshold(threshold);
_trigger.reset();
}
}

void reset() {
_trigger.reset();
_now = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(ClockSourceType::now().time_since_epoch()).count());
trigger_time = _now;
}

gr::work::Status processBulk(InputSpanLike auto& inputSpan, OutputSpanLike auto& outputSpan) {
const std::size_t nAvailable = inputSpan.size();
// FIXME: re-enable - there should be N_HISTORY samples kept in the history buffer to be able to back-date detected edges
// The following code early-returns if the number of available samples is less than N_HISTORY but never grows beyond that.
//
// const bool isSuttingDown = lifecycle::isShuttingDown(this->state());
// if (nAvailable < N_HISTORY && !isSuttingDown) {
// fmt::println("INSUFFICIENT_INPUT_ITEMS processBulk({}, {}) - {}", nAvailable, 0, isSuttingDown);
// return gr::work::Status::INSUFFICIENT_INPUT_ITEMS;
// }
// const std::size_t nProcess = isSuttingDown ? nAvailable : (nAvailable - N_HISTORY);

const std::size_t nProcess = nAvailable; // TODO: work-around
for (std::size_t i = 0UZ; i < nProcess; i++) {
const T sample = inputSpan[i];
_now += _period;

if (_trigger.processOne(sample) != NONE) { // edge detected
const auto genTag = [&](const std::string& triggerName) -> property_map {
const auto correctedEdgeTime = _now - static_cast<std::uint64_t>(gr::value(_trigger.lastEdgeIdx + _trigger.lastEdgeOffset)) * _period;
const auto correctedEdgeError = static_cast<std::uint64_t>(gr::uncertainty(_trigger.lastEdgeIdx + _trigger.lastEdgeOffset)) * _period;
const float triggerOffset = static_cast<float>(static_cast<std::uint64_t>(gr::value(_trigger.lastEdgeIdx + _trigger.lastEdgeOffset)) * _period);
return {
{gr::tag::TRIGGER_NAME.shortKey(), triggerName}, //
{gr::tag::TRIGGER_TIME.shortKey(), correctedEdgeTime}, //
{"trigger_time_error", correctedEdgeError}, //
{gr::tag::TRIGGER_OFFSET.shortKey(), triggerOffset}, // sub-sample offset
{gr::tag::CONTEXT.shortKey(), context} //
};
};

if (_trigger.lastEdge == RISING && !trigger_name_rising_edge.value.empty()) { // generate rising tag
const std::size_t nPublish = i + 1UZ;
if constexpr (UncertainValueLike<T>) {
outputSpan.publishTag(genTag(trigger_name_rising_edge), i - static_cast<std::size_t>(-_trigger.lastEdgeIdx.value));
} else {
outputSpan.publishTag(genTag(trigger_name_rising_edge), i - static_cast<std::size_t>(-_trigger.lastEdgeIdx));
}
std::copy_n(inputSpan.begin(), nPublish, outputSpan.begin());
inputSpan.consume(nPublish);
outputSpan.publish(nPublish);
return gr::work::Status::OK;
}

if (_trigger.lastEdge == FALLING && !trigger_name_falling_edge.value.empty()) { // generate rising tag
const std::size_t nPublish = i + 1UZ;
if constexpr (UncertainValueLike<T>) {
outputSpan.publishTag(genTag(trigger_name_falling_edge), i - static_cast<std::size_t>(-_trigger.lastEdgeIdx.value));
} else {
outputSpan.publishTag(genTag(trigger_name_falling_edge), i - static_cast<std::size_t>(-_trigger.lastEdgeIdx));
}
std::copy_n(inputSpan.begin(), nPublish, outputSpan.begin());
inputSpan.consume(nPublish);
outputSpan.publish(nPublish);
return gr::work::Status::OK;
}
}
}

// no trigger found - just copy data
std::copy_n(inputSpan.begin(), nProcess, outputSpan.begin());
inputSpan.consume(nProcess);
outputSpan.publish(nProcess);
return gr::work::Status::OK;
}
};

} // namespace gr::blocks::basic

const inline auto registerTrigger = gr::registerBlock<gr::blocks::basic::SchmittTrigger, gr::trigger::InterpolationMethod::NO_INTERPOLATION, std::int16_t, std::int32_t, float, gr::UncertainValue<float>>(gr::globalBlockRegistry()) //
+ gr::registerBlock<gr::blocks::basic::SchmittTrigger, gr::trigger::InterpolationMethod::BASIC_LINEAR_INTERPOLATION, std::int16_t, std::int32_t, float, gr::UncertainValue<float>>(gr::globalBlockRegistry()) //
+ gr::registerBlock<gr::blocks::basic::SchmittTrigger, gr::trigger::InterpolationMethod::LINEAR_INTERPOLATION, std::int16_t, std::int32_t, float, gr::UncertainValue<float>>(gr::globalBlockRegistry());

#endif // TRIGGER_HPP
10 changes: 6 additions & 4 deletions blocks/basic/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ add_ut_test(qa_Converter)
add_ut_test(qa_Selector)
add_ut_test(qa_sources)
add_ut_test(qa_DataSink)
add_ut_test(qa_TriggerBlocks)

if (ENABLE_BLOCK_REGISTRY AND ENABLE_BLOCK_PLUGINS)
add_ut_test(qa_BasicKnownBlocks)
add_ut_test(qa_BasicKnownBlocks)
endif ()
add_ut_test(qa_StreamToDataSet)
add_ut_test(qa_SyncBlock)

message(STATUS "###Python Include Dirs: ${Python3_INCLUDE_DIRS}")
if (PYTHON_AVAILABL AND ENABLE_BLOCK_REGISTRY AND ENABLE_BLOCK_PLUGINS)
add_ut_test(qa_PythonBlock)
if (PYTHON_AVAILABLE
AND ENABLE_BLOCK_REGISTRY
AND ENABLE_BLOCK_PLUGINS)
add_ut_test(qa_PythonBlock)
endif ()

125 changes: 125 additions & 0 deletions blocks/basic/test/qa_TriggerBlocks.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#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/basic/FunctionGenerator.hpp>
#include <gnuradio-4.0/basic/Trigger.hpp>
#include <gnuradio-4.0/basic/clock_source.hpp>
#include <gnuradio-4.0/testing/ImChartMonitor.hpp>
#include <gnuradio-4.0/testing/TagMonitors.hpp>

using namespace boost::ut;

const suite<"SchmittTrigger Block"> triggerTests = [] {
using namespace gr;
using namespace gr::basic;
using namespace gr::testing;

constexpr static float sample_rate = 1000.f; // 100 Hz
bool enableVisualTests = false;
if (std::getenv("DISABLE_SENSITIVE_TESTS") == nullptr) {
// conditionally enable visual tests outside the CI
boost::ext::ut::cfg<override> = {.tag = std::vector<std::string_view>{"visual", "benchmarks"}};
enableVisualTests = true;
}

"SchmittTrigger test"_test = [&enableVisualTests] {
Graph graph;

// create blocks
auto& clockSrc = graph.emplaceBlock<gr::basic::ClockSource<float>>({//
{"sample_rate", sample_rate}, {"n_samples_max", 1000U}, {"name", "ClockSource"},
{"tag_times",
std::vector<std::uint64_t>{
0U, // 0 ms - start - 50ms of bottom plateau
100'000'000U, // 100 ms - start - ramp-up
400'000'000U, // 300 ms - 50ms of bottom plateau
500'000'000U, // 500 ms - start ramp-down
800'000'000U // 700 ms - 100ms of bottom plateau
}},
{"tag_values",
std::vector<std::string>{
"CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=0", //
"CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=1", //
"CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=2", //
"CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=3", //
"CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=4" //
}},
{"do_zero_order_hold", true}});

auto& funcGen = graph.emplaceBlock<FunctionGenerator<float>>({{"sample_rate", sample_rate}, {"name", "FunctionGenerator"}, {"start_value", 0.1f}});
using namespace function_generator;
expect(funcGen.settings().set(createConstPropertyMap(0.1f), SettingsCtx{.context = "CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=0"}).empty());
expect(funcGen.settings().set(createParabolicRampPropertyMap(0.1f, 1.1f, .3f, 0.02f), SettingsCtx{.context = "CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=1"}).empty());
expect(funcGen.settings().set(createConstPropertyMap(1.1f), SettingsCtx{.context = "CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=2"}).empty());
expect(funcGen.settings().set(createParabolicRampPropertyMap(1.1f, 0.1f, .3f, 0.02f), SettingsCtx{.context = "CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=3"}).empty());
expect(funcGen.settings().set(createConstPropertyMap(0.1f), SettingsCtx{.context = "CMD_BP_START/FAIR.SELECTOR.C=1:S=1:P=4"}).empty());

auto& schmittTrigger = graph.emplaceBlock<gr::blocks::basic::SchmittTrigger<float, gr::trigger::InterpolationMethod::NO_INTERPOLATION>>({
{"name", "SchmittTrigger"}, //
{"threshold", .1f}, //
{"offset", .6f}, //
{"trigger_name_rising_edge", "MY_RISING_EDGE"}, //
{"trigger_name_falling_edge", "MY_FALLING_EDGE"} //
});
auto& tagSink = graph.emplaceBlock<TagSink<float, gr::testing::ProcessFunction::USE_PROCESS_ONE>>({{"name", "TagSink"}, {"log_tags", true}, {"log_samples", false}, {"verbose_console", true}});

auto& uiSink1 = graph.emplaceBlock<ImChartMonitor<float>>({{"name", "ImChartSink1"}});
auto& uiSink2 = graph.emplaceBlock<ImChartMonitor<float>>({{"name", "ImChartSink2"}});

// connect blocks
expect(eq(ConnectionResult::SUCCESS, graph.connect<"out">(clockSrc).to<"in">(funcGen))) << "connect clockSrc->funcGen";
expect(eq(ConnectionResult::SUCCESS, graph.connect<"out">(funcGen).to<"in">(schmittTrigger))) << "connect funcGen->schmittTrigger";
expect(eq(ConnectionResult::SUCCESS, graph.connect<"out">(schmittTrigger).to<"in">(tagSink))) << "connect schmittTrigger->tagSink";

// connect UI sinks (optional)
if (enableVisualTests) {
expect(eq(ConnectionResult::SUCCESS, graph.connect<"out">(funcGen).to<"in">(uiSink1))) << "connect funcGen->uiSink1";
expect(eq(ConnectionResult::SUCCESS, graph.connect<"out">(schmittTrigger).to<"in">(uiSink2))) << "connect schmittTrigger->uiSink2";
}

std::thread uiLoop([&uiSink1, &uiSink2]() {
bool drawUI = true;
while (drawUI) {
using enum gr::work::Status;
drawUI = false;
drawUI |= uiSink1.draw({{"reset_view", true}}) != DONE;
drawUI |= uiSink2.draw({}) != DONE;
std::this_thread::sleep_for(std::chrono::milliseconds(40));
}
std::this_thread::sleep_for(std::chrono::seconds(1)); // wait before shutting down
});

gr::scheduler::Simple sched{std::move(graph)};
expect(sched.runAndWait().has_value()) << "runAndWait";

uiLoop.join();

expect(eq(tagSink._tags.size(), 7UZ)) << "expected total number of tags";

// filter tags for those generated on rising and falling edges
std::vector<std::size_t> rising_edge_indices;
std::vector<std::size_t> falling_edge_indices;

for (const auto& tag : tagSink._tags) {
if (!tag.map.contains(std::string(gr::tag::TRIGGER_NAME.shortKey()))) {
continue;
}
std::string trigger_name = std::get<std::string>(tag.map.at(std::string(gr::tag::TRIGGER_NAME.shortKey())));
if (trigger_name == "MY_RISING_EDGE") {
rising_edge_indices.push_back(tag.index);
} else if (trigger_name == "MY_FALLING_EDGE") {
falling_edge_indices.push_back(tag.index);
}
}
expect(eq(rising_edge_indices.size(), 1UZ)) << "expected one rising edge";
expect(eq(falling_edge_indices.size(), 1UZ)) << "expected one falling edge";

expect(approx(rising_edge_indices[0], 250UZ, 8UZ)) << "detected rising edge index";
expect(approx(falling_edge_indices[0], 650UZ, 8UZ)) << "detected falling edge index";
};
};

int main() { /* not needed for UT */ }

0 comments on commit 0043518

Please sign in to comment.