-
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
d1c867b
commit 0043518
Showing
3 changed files
with
288 additions
and
4 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,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 |
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,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 */ } |