-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
3f841a3
commit f319c57
Showing
3 changed files
with
307 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
#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>, NoDefaultTagForwarding> { | ||
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<bool, "forward tags ", Doc<"false: emit only tags for detected edges">> forward_tag{true}; | ||
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, forward_tag, 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}; | ||
|
||
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 start() { 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::optional<std::size_t> nextEoSTag = samples_to_eos_tag(in); | ||
if (inputSpan.size() < N_HISTORY && nextEoSTag.value_or(0) >= N_HISTORY) { | ||
return gr::work::Status::INSUFFICIENT_INPUT_ITEMS; | ||
} | ||
const std::size_t nProcess = inputSpan.size() - N_HISTORY * nextEoSTag.has_value(); // limit input data to have enough samples to back-date detected trigger if necessary | ||
|
||
auto forwardTags = [&](std::size_t maxRelIndex) { | ||
if (!forward_tag) { | ||
return; | ||
} | ||
for (const auto& tag : inputSpan.rawTags) { | ||
const auto relIndex = static_cast<std::ptrdiff_t>(tag.index) - static_cast<std::ptrdiff_t>(inputSpan.streamIndex); | ||
if (relIndex >= 0 && static_cast<std::size_t>(relIndex) <= maxRelIndex) { | ||
outputSpan.publishTag(tag.map, static_cast<std::size_t>(relIndex)); | ||
} | ||
} | ||
}; | ||
|
||
auto publishEdge = [&](const std::string& triggerName, std::ptrdiff_t edgePosition) { | ||
const std::size_t edgePosUnsigned = std::max(0UZ, static_cast<std::size_t>(edgePosition)); | ||
|
||
forwardTags(edgePosUnsigned); | ||
|
||
// Calculate properties for the edge tag | ||
const auto edgeIdxOffset = _trigger.lastEdgeIdx + _trigger.lastEdgeOffset; | ||
outputSpan.publishTag( | ||
property_map{ | ||
// | ||
{gr::tag::TRIGGER_NAME.shortKey(), triggerName}, // | ||
{gr::tag::TRIGGER_TIME.shortKey(), static_cast<uint64_t>(_now - gr::value(edgeIdxOffset) * _period)}, // | ||
{"trigger_time_error", static_cast<uint64_t>(gr::uncertainty(edgeIdxOffset) * _period)}, // | ||
{gr::tag::TRIGGER_OFFSET.shortKey(), static_cast<float>(gr::value(edgeIdxOffset) * _period)}, // | ||
{gr::tag::CONTEXT.shortKey(), context} // | ||
}, | ||
edgePosUnsigned); | ||
|
||
// copy samples up to nPublish | ||
std::copy_n(inputSpan.begin(), edgePosUnsigned, outputSpan.begin()); | ||
std::ignore = inputSpan.consume(edgePosUnsigned); | ||
outputSpan.publish(edgePosUnsigned); | ||
return gr::work::Status::OK; | ||
}; | ||
|
||
for (std::size_t i = 0; i < nProcess; ++i) { | ||
const T sample = inputSpan[i]; | ||
_now += _period; | ||
|
||
if (_trigger.processOne(sample) != NONE) { // edge detected | ||
const std::ptrdiff_t edgePosition = static_cast<std::ptrdiff_t>(i) + static_cast<std::ptrdiff_t>(gr::value(_trigger.lastEdgeIdx)); | ||
|
||
if (_trigger.lastEdge == RISING && !trigger_name_rising_edge.value.empty()) { | ||
return publishEdge(trigger_name_rising_edge, edgePosition); | ||
} | ||
|
||
if (_trigger.lastEdge == FALLING && !trigger_name_falling_edge.value.empty()) { | ||
return publishEdge(trigger_name_falling_edge, edgePosition); | ||
} | ||
} | ||
} | ||
|
||
// no trigger found - just copy samples & tags | ||
std::copy_n(inputSpan.begin(), nProcess, outputSpan.begin()); | ||
forwardTags(nProcess - 1UZ); | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
#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::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; | ||
} | ||
|
||
using enum gr::trigger::InterpolationMethod; | ||
"SchmittTrigger"_test = | ||
[&enableVisualTests]<class Method> { | ||
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, Method::value>>({ | ||
{"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", false}}); | ||
|
||
// connect non-UI 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).template to<"in">(tagSink))) << "connect schmittTrigger->tagSink"; | ||
|
||
gr::scheduler::Simple sched{std::move(graph)}; // declared here to ensure life-time of graph and blocks inside. | ||
if (enableVisualTests) { // execute UI-based tests | ||
auto& uiSink1 = sched.graph().emplaceBlock<ImChartMonitor<float>>({{"name", "ImChartSink1"}}); | ||
auto& uiSink2 = sched.graph().emplaceBlock<ImChartMonitor<float>>({{"name", "ImChartSink2"}}); | ||
// connect UI blocks | ||
expect(eq(ConnectionResult::SUCCESS, sched.graph().connect<"out">(funcGen).to<"in">(uiSink1))) << "connect funcGen->uiSink1"; | ||
expect(eq(ConnectionResult::SUCCESS, sched.graph().connect<"out">(schmittTrigger).template 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 | ||
}); | ||
|
||
expect(sched.runAndWait().has_value()) << "runAndWait"; | ||
|
||
uiLoop.join(); | ||
enableVisualTests = false; // only for first test | ||
} else { | ||
// non-UI test | ||
expect(sched.runAndWait().has_value()) << "runAndWait"; | ||
} | ||
|
||
expect(eq(tagSink._tags.size(), 7UZ)) << fmt::format("test {} : expected total number of tags", magic_enum::enum_name(Method::value)); | ||
|
||
// 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)) << fmt::format("test {} : expected one rising edge", magic_enum::enum_name(Method::value)); | ||
expect(eq(falling_edge_indices.size(), 1UZ)) << fmt::format("test {} : expected one falling edge", magic_enum::enum_name(Method::value)); | ||
|
||
if (Method::value == NO_INTERPOLATION) { // edge position once crossing the threshold | ||
expect(approx(rising_edge_indices[0], 278UZ, 2UZ)) << fmt::format("test {} : detected rising edge index", magic_enum::enum_name(Method::value)); | ||
expect(approx(falling_edge_indices[0], 678UZ, 2UZ)) << fmt::format("test {} : detected falling edge index", magic_enum::enum_name(Method::value)); | ||
} else { // exact edge position | ||
expect(approx(rising_edge_indices[0], 250UZ, 2UZ)) << fmt::format("test {} : detected rising edge index", magic_enum::enum_name(Method::value)); | ||
expect(approx(falling_edge_indices[0], 650UZ, 2UZ)) << fmt::format("test {} : detected falling edge index", magic_enum::enum_name(Method::value)); | ||
} | ||
} | | ||
std::tuple<std::integral_constant<gr::trigger::InterpolationMethod, LINEAR_INTERPOLATION>, // | ||
std::integral_constant<gr::trigger::InterpolationMethod, BASIC_LINEAR_INTERPOLATION>, // | ||
std::integral_constant<gr::trigger::InterpolationMethod, NO_INTERPOLATION>>{}; | ||
}; | ||
|
||
int main() { /* not needed for UT */ } |