diff --git a/CMakeLists.txt b/CMakeLists.txt index cb6eebb..105d701 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ if(TARGET plotjuggler_sdk::plugin_sdk) add_subdirectory(toolbox_colormap) add_subdirectory(toolbox_quaternion) add_subdirectory(toolbox_reactive_scripts_editor) + add_subdirectory(toolbox_filter_editor) add_subdirectory(toolbox_mosaico) return() endif() @@ -153,5 +154,6 @@ else() add_subdirectory(toolbox_fft) add_subdirectory(toolbox_colormap) add_subdirectory(toolbox_quaternion) + add_subdirectory(toolbox_filter_editor) add_subdirectory(toolbox_mosaico) endif() diff --git a/extern/plotjuggler_core b/extern/plotjuggler_core index 0c3aa51..421ad1b 160000 --- a/extern/plotjuggler_core +++ b/extern/plotjuggler_core @@ -1 +1 @@ -Subproject commit 0c3aa51bab3b4bf7e3e361b2bf5239665e42dc1f +Subproject commit 421ad1b335c10a4596e7f627dff008aed3f86ffa diff --git a/toolbox_filter_editor/CMakeLists.txt b/toolbox_filter_editor/CMakeLists.txt new file mode 100644 index 0000000..c649a5b --- /dev/null +++ b/toolbox_filter_editor/CMakeLists.txt @@ -0,0 +1,48 @@ +find_package(nlohmann_json REQUIRED) + +include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedManifest.cmake) +include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedUi.cmake) + +add_library(toolbox_filter_editor_plugin SHARED filter_editor_plugin.cpp) +target_compile_features(toolbox_filter_editor_plugin PRIVATE cxx_std_20) +target_compile_options(toolbox_filter_editor_plugin PRIVATE ${PJ_WARNING_FLAGS}) + +target_link_libraries(toolbox_filter_editor_plugin PRIVATE + plotjuggler_sdk::plugin_sdk + nlohmann_json::nlohmann_json + ${CMAKE_DL_LIBS} +) + +pj_embed_manifest(toolbox_filter_editor_plugin + HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_manifest.hpp + VAR_NAME kFilterEditorManifest +) + +pj_embed_ui(toolbox_filter_editor_plugin + UI_FILE ${CMAKE_CURRENT_SOURCE_DIR}/filter_editor_dialog.ui + HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_dialog_ui.hpp + VAR_NAME kFilterEditorDialogUi +) + +# v4 plugin discovery: emit a sidecar .pjmanifest.json the host can scan +# pre-dlopen. Content derived from manifest.json + {abi_major, family}. +pj_emit_plugin_manifest(toolbox_filter_editor_plugin + FAMILY toolbox + MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json +) + +# Built-in predefined transforms catalogue lives in this plugin (it provides +# them); the SDK contract they implement is in plotjuggler_sdk::plugin_sdk. +# Tests guard the per-transform PJ3-mirror math + the SDK contract (the +# default appendTail loops calculateNextPoint, save/load round-trips). +if(TARGET GTest::gtest_main) + add_executable(builtin_transforms_test builtin_transforms_test.cpp) + target_compile_features(builtin_transforms_test PRIVATE cxx_std_20) + target_compile_options(builtin_transforms_test PRIVATE ${PJ_WARNING_FLAGS}) + target_link_libraries(builtin_transforms_test PRIVATE + plotjuggler_sdk::plugin_sdk + nlohmann_json::nlohmann_json + GTest::gtest_main + ) + add_test(NAME builtin_transforms_test COMMAND builtin_transforms_test) +endif() diff --git a/toolbox_filter_editor/builtin_transforms.hpp b/toolbox_filter_editor/builtin_transforms.hpp new file mode 100644 index 0000000..a5722be --- /dev/null +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -0,0 +1,10 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +// The 12 builtin Filter Transform strategies live in the SDK +// (plotjuggler_sdk/pj_plugins/filter_protocol/include/pj_plugins/sdk/builtin_transforms.hpp). +// This shim re-exports them so the plugin and the host can both consume the +// same source. + +#include "pj_plugins/sdk/builtin_transforms.hpp" diff --git a/toolbox_filter_editor/builtin_transforms_test.cpp b/toolbox_filter_editor/builtin_transforms_test.cpp new file mode 100644 index 0000000..5ac730c --- /dev/null +++ b/toolbox_filter_editor/builtin_transforms_test.cpp @@ -0,0 +1,214 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +#include "builtin_transforms.hpp" + +#include + +#include +#include +#include + +namespace { + +using namespace PJ::sdk; // NOLINT + +// Helper: apply a transform to (xs, ys) pairs and return output points. +std::vector apply(FilterTransform& t, std::vector xs, std::vector ys) { + std::vector in; + in.reserve(xs.size()); + for (size_t i = 0; i < xs.size(); ++i) { + in.push_back({xs[i], ys[i]}); + } + return t.applyBatch(in); +} + +TEST(Transforms, Absolute) { + AbsoluteTransform t; + auto out = apply(t, {0, 1, 2}, {-3, 4, -5}); + ASSERT_EQ(out.size(), 3u); + EXPECT_DOUBLE_EQ(out[0].y, 3.0); + EXPECT_DOUBLE_EQ(out[2].y, 5.0); +} + +TEST(Transforms, ScaleAndOffset) { + ScaleTransform t; + t.value_scale = 2.0; + t.value_offset = 1.0; + t.time_offset = 10.0; + auto out = apply(t, {0, 1}, {3, 4}); + EXPECT_DOUBLE_EQ(out[0].x, 10.0); + EXPECT_DOUBLE_EQ(out[0].y, 7.0); + EXPECT_DOUBLE_EQ(out[1].y, 9.0); +} + +TEST(Transforms, DerivativeActualDt) { + DerivativeTransform t; + auto out = apply(t, {0, 1, 2}, {0, 10, 30}); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].y, 10.0); + EXPECT_DOUBLE_EQ(out[1].y, 20.0); +} + +TEST(Transforms, IntegralTrapezoid) { + IntegralTransform t; + auto out = apply(t, {0, 1, 2}, {0, 2, 4}); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].y, 1.0); + EXPECT_DOUBLE_EQ(out[1].y, 4.0); +} + +TEST(Transforms, MovingAverageWindow) { + MovingAverageTransform t; + t.window = 2; + auto out = apply(t, {0, 1, 2, 3}, {2, 4, 6, 8}); + ASSERT_EQ(out.size(), 4u); + EXPECT_DOUBLE_EQ(out[0].y, 2.0); // padded + EXPECT_DOUBLE_EQ(out[1].y, 3.0); // (2+4)/2 + EXPECT_DOUBLE_EQ(out[3].y, 7.0); // (6+8)/2 +} + +TEST(Transforms, MovingRMS) { + MovingRMSTransform t; + t.window = 2; + auto out = apply(t, {0, 1}, {3, 4}); + EXPECT_DOUBLE_EQ(out[0].y, 3.0); + EXPECT_DOUBLE_EQ(out[1].y, std::sqrt((9.0 + 16.0) / 2.0)); +} + +TEST(Transforms, BinaryFilterGreater) { + BinaryFilterTransform t; + t.op = BinaryOp::kGreater; + t.a = 5.0; + auto out = apply(t, {0, 1, 2}, {3, 5, 7}); + EXPECT_DOUBLE_EQ(out[0].y, 0.0); + EXPECT_DOUBLE_EQ(out[1].y, 0.0); + EXPECT_DOUBLE_EQ(out[2].y, 1.0); +} + +TEST(Transforms, BinaryFilterRange) { + BinaryFilterTransform t; + t.op = BinaryOp::kRange; + t.a = 2.0; + t.b = 6.0; + auto out = apply(t, {0, 1, 2}, {1, 4, 7}); + EXPECT_DOUBLE_EQ(out[0].y, 0.0); + EXPECT_DOUBLE_EQ(out[1].y, 1.0); + EXPECT_DOUBLE_EQ(out[2].y, 0.0); +} + +TEST(Transforms, TimeSincePrevious) { + TimeSincePreviousTransform t; + auto out = apply(t, {0, 2, 5}, {9, 9, 9}); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].y, 2.0); + EXPECT_DOUBLE_EQ(out[1].y, 3.0); +} + +TEST(Transforms, SamplesCounter) { + SamplesCounterTransform t; + t.samples_ms = 2000; + auto out = apply(t, {0, 1, 2, 3}, {0, 0, 0, 0}); + ASSERT_EQ(out.size(), 4u); + EXPECT_DOUBLE_EQ(out[0].y, 0.0); + EXPECT_DOUBLE_EQ(out[2].y, 2.0); +} + +TEST(Transforms, NoneIsPassthrough) { + NoneTransform t; + auto out = apply(t, {0, 1}, {5, 6}); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[1].y, 6.0); +} + +TEST(Transforms, FactoryRegistration) { + registerAllTransforms(); + auto& f = FilterTransformFactory::instance(); + const auto ids = f.registeredIds(); + EXPECT_FALSE(ids.empty()); + // All registered ids should be creatable + for (const auto& id : ids) { + auto t = f.create(id); + EXPECT_NE(t, nullptr) << "Could not create: " << id; + } +} + +TEST(Transforms, StreamSafeFlags) { + registerAllTransforms(); + // Known stream-safe + EXPECT_TRUE(AbsoluteTransform{}.isStreamSafe()); + EXPECT_TRUE(ScaleTransform{}.isStreamSafe()); + EXPECT_TRUE(DerivativeTransform{}.isStreamSafe()); + EXPECT_TRUE(MovingAverageTransform{}.isStreamSafe()); + EXPECT_TRUE(MovingRMSTransform{}.isStreamSafe()); + EXPECT_TRUE(MovingVarianceTransform{}.isStreamSafe()); + EXPECT_TRUE(OutlierRemovalTransform{}.isStreamSafe()); + EXPECT_TRUE(BinaryFilterTransform{}.isStreamSafe()); + EXPECT_TRUE(TimeSincePreviousTransform{}.isStreamSafe()); + // Known non-safe (need full history) + EXPECT_FALSE(IntegralTransform{}.isStreamSafe()); + EXPECT_FALSE(SamplesCounterTransform{}.isStreamSafe()); +} + +// --------------------------------------------------------------------------- +// Streaming equivalence: calculateNextPoint matches applyBatch +// --------------------------------------------------------------------------- + +void expectStreamingMatchesBatch(FilterTransform& t_batch, FilterTransform& t_stream) { + std::vector full; + for (int i = 0; i < 30; ++i) { + full.push_back({static_cast(i), std::sin(i * 0.3) * 5.0 + static_cast(i % 5)}); + } + // Batch + const auto batch = t_batch.applyBatch(full); + // Streaming point-by-point + t_stream.reset(); + std::vector streamed; + for (const auto& p : full) { + if (auto r = t_stream.calculateNextPoint(p)) { + streamed.push_back(*r); + } + } + ASSERT_EQ(batch.size(), streamed.size()); + for (size_t i = 0; i < batch.size(); ++i) { + EXPECT_NEAR(batch[i].x, streamed[i].x, 1e-9) << "x mismatch at " << i; + EXPECT_NEAR(batch[i].y, streamed[i].y, 1e-9) << "y mismatch at " << i; + } +} + +TEST(Transforms, StreamingMatchesBatch) { + { + AbsoluteTransform a, b; + expectStreamingMatchesBatch(a, b); + } + { + DerivativeTransform a, b; + expectStreamingMatchesBatch(a, b); + } + { + IntegralTransform a, b; + expectStreamingMatchesBatch(a, b); + } + { + MovingAverageTransform a, b; + a.window = b.window = 5; + expectStreamingMatchesBatch(a, b); + } + { + MovingRMSTransform a, b; + a.window = b.window = 5; + expectStreamingMatchesBatch(a, b); + } + { + TimeSincePreviousTransform a, b; + expectStreamingMatchesBatch(a, b); + } + { + BinaryFilterTransform a, b; + a.op = b.op = BinaryOp::kGreater; + a.a = b.a = 2.0; + expectStreamingMatchesBatch(a, b); + } +} + +} // namespace diff --git a/toolbox_filter_editor/conanfile.py b/toolbox_filter_editor/conanfile.py new file mode 100644 index 0000000..7949972 --- /dev/null +++ b/toolbox_filter_editor/conanfile.py @@ -0,0 +1,24 @@ +import os +from conan import ConanFile + + +_SDK_VERSION = ( + open(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SDK_VERSION")) + .read() + .strip() +) + + +class ToolboxFilterEditorConan(ConanFile): + name = "toolbox_filter_editor" + version = "0" + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeDeps", "CMakeToolchain" + requires = ( + f"plotjuggler_sdk/{_SDK_VERSION}", + "gtest/1.17.0", + "nlohmann_json/3.12.0", + ) + default_options = { + "*:shared": False, + } diff --git a/toolbox_filter_editor/custom_function_engine.hpp b/toolbox_filter_editor/custom_function_engine.hpp new file mode 100644 index 0000000..eab6ecb --- /dev/null +++ b/toolbox_filter_editor/custom_function_engine.hpp @@ -0,0 +1,190 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +// CustomFunctionEngine — Lua evaluation core for the Custom Function toolbox. +// +// Faithful port of PlotJuggler 3's transforms/lua_custom_function.cpp. A custom +// function derives ONE output series from a "main" input series plus N optional +// "additional" sources. The user-supplied body becomes the body of a Lua +// function with the exact PJ3 signature: +// +// function calc(time, value, v1, v2, ... vN) +// +// end +// +// where, for each sample i of the main series: +// - `time` = main timestamp[i] +// - `value` = main value[i] +// - `vk` = additional[k] value sampled at `time` (nearest sample; NaN if +// the additional series is empty) +// +// The body returns one of (matching PJ3 LuaCustomFunction::calculatePoints): +// - a single number -> point (time, number) +// - two numbers (t, v) -> point (t, v) +// - a table of {t, v} pairs -> several points +// +// Header-only (sol2 in the header) so the engine is unit-testable without the +// plugin host. No Qt, no datastore — input/output are plain doubles. + +#include +#include +#include +#include +#include +#include + +namespace pj_custom_function { + +/// Read-only timestamp/value view over one input series (timestamps ascending). +struct SeriesAccessor { + std::vector timestamps; + std::vector values; + + [[nodiscard]] size_t size() const { + return timestamps.size(); + } + [[nodiscard]] bool empty() const { + return timestamps.empty(); + } + + /// Value at the sample whose timestamp is closest to `t` (PJ3 getIndexFromX + /// semantics). Returns NaN when the series is empty. + [[nodiscard]] double valueAtNearest(double t) const { + if (timestamps.empty()) { + return std::numeric_limits::quiet_NaN(); + } + auto it = std::lower_bound(timestamps.begin(), timestamps.end(), t); + if (it == timestamps.end()) { + return values.back(); + } + if (it == timestamps.begin()) { + return values.front(); + } + size_t idx = static_cast(it - timestamps.begin()); + // Pick whichever of idx-1 / idx is closer in time. + double d_hi = timestamps[idx] - t; + double d_lo = t - timestamps[idx - 1]; + return (d_lo <= d_hi) ? values[idx - 1] : values[idx]; + } +}; + +/// One derived output sample. +struct OutputPoint { + double t; + double v; +}; + +/// Compiles + runs a PJ3-style custom function. Construct, compile() once, then +/// evaluate() as the main series grows (incremental via `after_timestamp`). +class CustomFunctionEngine { + public: + /// Compile `global_code` (run once, may define helpers/vars) and wrap + /// `function_body` into `calc(time, value, v1..vN)` for `num_additional` + /// extra sources. Returns "" on success or a human-readable error. + std::string compile(const std::string& global_code, const std::string& function_body, size_t num_additional) { + num_additional_ = num_additional; + lua_ = sol::state{}; + lua_.open_libraries(sol::lib::base, sol::lib::string, sol::lib::math, sol::lib::table); + calc_ = sol::protected_function{}; + + if (!global_code.empty()) { + auto result = lua_.safe_script(global_code, sol::script_pass_on_error); + if (!result.valid()) { + sol::error err = result; + return std::string("Global: ") + err.what(); + } + } + + std::string signature = "function calc(time, value"; + for (size_t i = 1; i <= num_additional; ++i) { + signature += ", v" + std::to_string(i); + } + signature += ")\n" + function_body + "\nend"; + + auto result = lua_.safe_script(signature, sol::script_pass_on_error); + if (!result.valid()) { + sol::error err = result; + return err.what(); + } + calc_ = lua_.get("calc"); + if (!calc_.valid()) { + return "internal error: calc() not defined"; + } + return ""; + } + + /// Evaluate over every main sample with `timestamp > after_timestamp`, + /// looking up each additional source at the main timestamp. Appends derived + /// points to `out`. Returns "" on success or a human-readable error. + std::string evaluate( + const SeriesAccessor& main, const std::vector& additional, double after_timestamp, + std::vector& out) { + if (!calc_.valid()) { + return "internal error: function not compiled"; + } + if (additional.size() != num_additional_) { + return "internal error: additional source count mismatch"; + } + + std::vector args; + args.reserve(num_additional_ + 2); + + for (size_t i = 0; i < main.size(); ++i) { + const double t = main.timestamps[i]; + if (t <= after_timestamp) { + continue; + } + args.clear(); + args.push_back(t); + args.push_back(main.values[i]); + for (const SeriesAccessor* src : additional) { + args.push_back(src != nullptr ? src->valueAtNearest(t) : std::numeric_limits::quiet_NaN()); + } + + sol::protected_function_result result = calc_(sol::as_args(args)); + if (!result.valid()) { + sol::error err = result; + return err.what(); + } + if (std::string e = appendResult(result, t, out); !e.empty()) { + return e; + } + } + return ""; + } + + private: + // Interpret the Lua return value(s) exactly like PJ3 LuaCustomFunction. + static std::string appendResult(sol::protected_function_result& result, double time, std::vector& out) { + const int count = result.return_count(); + if (count >= 2 && result.get_type(0) == sol::type::number && result.get_type(1) == sol::type::number) { + out.push_back({result.get(0), result.get(1)}); + return ""; + } + if (count == 1 && result.get_type(0) == sol::type::number) { + out.push_back({time, result.get(0)}); + return ""; + } + if (count == 1 && result.get_type(0) == sol::type::table) { + sol::table table = result.get(0); + for (size_t i = 1; i <= table.size(); ++i) { + sol::object element = table.get(i); + if (!element.is()) { + return "Wrong return object: expected an array of {time, value} pairs"; + } + sol::table pair = element.as(); + out.push_back({pair[1].get(), pair[2].get()}); + } + return ""; + } + return "Wrong return object: expecting either a single value, two values (time, value) " + "or an array of two-sized arrays (time, value)"; + } + + sol::state lua_; + sol::protected_function calc_; + size_t num_additional_ = 0; +}; + +} // namespace pj_custom_function diff --git a/toolbox_filter_editor/custom_function_engine_test.cpp b/toolbox_filter_editor/custom_function_engine_test.cpp new file mode 100644 index 0000000..e9f447b --- /dev/null +++ b/toolbox_filter_editor/custom_function_engine_test.cpp @@ -0,0 +1,140 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +#include "custom_function_engine.hpp" + +#include + +#include + +namespace { + +using pj_custom_function::CustomFunctionEngine; +using pj_custom_function::OutputPoint; +using pj_custom_function::SeriesAccessor; + +SeriesAccessor makeSeries(std::vector ts, std::vector vs) { + SeriesAccessor s; + s.timestamps = std::move(ts); + s.values = std::move(vs); + return s; +} + +TEST(CustomFunctionEngine, SingleValueReturn) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "return value * 2.0", 0), ""); + + auto main = makeSeries({1.0, 2.0, 3.0}, {10.0, 20.0, 30.0}); + std::vector out; + ASSERT_EQ(engine.evaluate(main, {}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 3u); + EXPECT_DOUBLE_EQ(out[0].t, 1.0); + EXPECT_DOUBLE_EQ(out[0].v, 20.0); + EXPECT_DOUBLE_EQ(out[2].v, 60.0); +} + +TEST(CustomFunctionEngine, TimeValuePairReturn) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "return time + 100.0, value + 1.0", 0), ""); + + auto main = makeSeries({1.0, 2.0}, {10.0, 20.0}); + std::vector out; + ASSERT_EQ(engine.evaluate(main, {}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].t, 101.0); + EXPECT_DOUBLE_EQ(out[0].v, 11.0); + EXPECT_DOUBLE_EQ(out[1].t, 102.0); + EXPECT_DOUBLE_EQ(out[1].v, 21.0); +} + +TEST(CustomFunctionEngine, TableOfPairsReturn) { + CustomFunctionEngine engine; + // Emit two points per input sample. + ASSERT_EQ(engine.compile("", "return { {time, value}, {time + 0.5, value * 10.0} }", 0), ""); + + auto main = makeSeries({1.0}, {2.0}); + std::vector out; + ASSERT_EQ(engine.evaluate(main, {}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].t, 1.0); + EXPECT_DOUBLE_EQ(out[0].v, 2.0); + EXPECT_DOUBLE_EQ(out[1].t, 1.5); + EXPECT_DOUBLE_EQ(out[1].v, 20.0); +} + +TEST(CustomFunctionEngine, AdditionalSourcesSampledByTime) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "return value + v1 + v2", 2), ""); + + auto main = makeSeries({1.0, 2.0, 3.0}, {1.0, 2.0, 3.0}); + auto v1 = makeSeries({1.0, 2.0, 3.0}, {10.0, 20.0, 30.0}); + auto v2 = makeSeries({1.0, 2.0, 3.0}, {100.0, 200.0, 300.0}); + + std::vector out; + ASSERT_EQ(engine.evaluate(main, {&v1, &v2}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 3u); + EXPECT_DOUBLE_EQ(out[0].v, 111.0); + EXPECT_DOUBLE_EQ(out[1].v, 222.0); + EXPECT_DOUBLE_EQ(out[2].v, 333.0); +} + +TEST(CustomFunctionEngine, GlobalCodeIsAvailableToFunction) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("scale = 3.0", "return value * scale", 0), ""); + + auto main = makeSeries({0.0}, {7.0}); + std::vector out; + ASSERT_EQ(engine.evaluate(main, {}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 1u); + EXPECT_DOUBLE_EQ(out[0].v, 21.0); +} + +TEST(CustomFunctionEngine, IncrementalAfterTimestamp) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "return value", 0), ""); + + auto main = makeSeries({1.0, 2.0, 3.0, 4.0}, {10.0, 20.0, 30.0, 40.0}); + std::vector out; + // Only samples strictly after t=2.0 should be emitted. + ASSERT_EQ(engine.evaluate(main, {}, 2.0, out), ""); + + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out[0].t, 3.0); + EXPECT_DOUBLE_EQ(out[1].t, 4.0); +} + +TEST(CustomFunctionEngine, CompileErrorReturnsMessage) { + CustomFunctionEngine engine; + std::string err = engine.compile("", "this is not valid lua )(", 0); + EXPECT_FALSE(err.empty()); +} + +TEST(CustomFunctionEngine, RuntimeErrorReturnsMessage) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "error('boom')", 0), ""); + + auto main = makeSeries({1.0}, {1.0}); + std::vector out; + std::string err = engine.evaluate(main, {}, -1e18, out); + EXPECT_FALSE(err.empty()); +} + +TEST(CustomFunctionEngine, EmptyAdditionalSourceYieldsNaN) { + CustomFunctionEngine engine; + ASSERT_EQ(engine.compile("", "if v1 ~= v1 then return -1.0 else return v1 end", 1), ""); + + auto main = makeSeries({1.0}, {5.0}); + SeriesAccessor empty; // no samples -> NaN + std::vector out; + ASSERT_EQ(engine.evaluate(main, {&empty}, -1e18, out), ""); + + ASSERT_EQ(out.size(), 1u); + EXPECT_DOUBLE_EQ(out[0].v, -1.0); +} + +} // namespace diff --git a/toolbox_filter_editor/filter_editor_dialog.ui b/toolbox_filter_editor/filter_editor_dialog.ui new file mode 100644 index 0000000..db6624b --- /dev/null +++ b/toolbox_filter_editor/filter_editor_dialog.ui @@ -0,0 +1,340 @@ + + + FilterEditorDialog + + + Filter Editor + + + 8 + 10 + 10 + 10 + 10 + + + + + <b>Preview</b> + + + + + + + QFrame::StyledPanel + + + 02 + + + 0180 + + + + + + + 10 + + + + + 2200 + 32016777215 + + 0 + 0 + 0 + 0 + + + <b>Source curve:</b> + + + + + + + 01 + + + + QAbstractItemView::ExtendedSelection + + + + + + + + + + + + 0 + 0 + 0 + 0 + + + + + + + Alias: + Qt::AlignRight|Qt::AlignVCenter + + + + + 3000 + 50016777215 + Qt::ClickFocus + false + + + + + Qt::Horizontal + 4020 + + + + + AutoZoom + true + + + + + + + + <b>Transform:</b> + + + + + 10 + + + 1800 + 24016777215 + + + + + + 0 + 0 + 0 + 0 + + + + + + + + 0 + <b>Apply offset</b> + + + 1200Time offset + 80167772150.0 + Qt::Horizontal4020 + + + + + 1200Value offset: + 80167772150.0 + Qt::Horizontal4020 + + + <b>Scale (multiply value)</b> + + + 1200Value multiplier: + 80167772151.0 + Qt::Horizontal4020 + + + + + Useful constants: + Qt::NoFocusRad to Deg + Qt::NoFocusDeg to Rad + Qt::Horizontal4020 + + + + + + + + + + + 0 + trueTime Difference (dT): + + + + + Use the actual difference in time between two consecutive points. + Actual + true + + + (It might be noisy!) + + + Use a fixed, custom value of dT. + Custom: + + + + + 80167772151.0 + Calculate the correct delta time. It tries to filter out outliers.Guess dT + Qt::Horizontal4020 + + + + + + + + + + + + + 0 + trueSelect the size of the window + + + Samples count: + 10016777215110000010 + + + The Moving Average filter introduces a delay. Checking this, the signal will look centered.Compensate time offset + + + Apply square root + (i.e., convert to standard deviation) + Qt::Horizontal4020 + + + + + + + + + + + 0 + trueRemove outliers: + Outliers are detected using the difference between the current 1st order derivative and the previous one.true + + + Max ratio + 12016777215110.0100000.0100.0 + + + Qt::Vertical2040 + + + + + + + + + 0 + Count the number of data points in the given time interval:true + + + Milliseconds: + 100019999991000 + Qt::Horizontal4020 + + + + + + + + + + + 0 + trueReturn 1 (true) / 0 (false) based on the condition below: + + + 10 + Equal (==). Works only with integersfalse + Less (<) + LessEqual (<=) + Greater (>)true + GreaterEqual (>=) + Range (included) + + + + + 120167772150 + 120167772150 + Qt::Horizontal4020 + + + + + + + + + Qt::Vertical + 200 + + + + + + + + + + + + + + + + + + + + + CopyCopy this filter (function + parameters) to the clipboard + PasteApply a copied filter to the selected source + ResetClear the filter (restore the original signal) + + + Qt::Horizontal + 4020 + + + Cancel + Savetrue + Generate time seriesApply the filter and add the result as a new persistent series in the datastore and curve tree + + + + + + diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp new file mode 100644 index 0000000..5b61aac --- /dev/null +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -0,0 +1,1209 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +// Filter Editor toolbox — PlotJuggler 3's "Apply filter to data" ported to a +// PJ4 toolbox panel. Pick a source curve, choose a built-in transform +// (Absolute, Scale, Derivative, Integral, Moving Average/RMS/Variance, Outlier +// +// Built-in math: transforms.hpp. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "builtin_transforms.hpp" +#include "filter_editor_dialog_ui.hpp" +#include "filter_editor_manifest.hpp" +#include "pj_plugins/sdk/filter_registry_service.hpp" + +namespace { + +using PJ::sdk::BinaryFilterTransform; +using PJ::sdk::BinaryOp; +using PJ::sdk::DerivativeTransform; +using PJ::sdk::FilterRegistryService; +using PJ::sdk::FilterRegistryView; +using PJ::sdk::FilterTransform; +using PJ::sdk::IntegralTransform; +using PJ::sdk::MovingAverageTransform; +using PJ::sdk::MovingRMSTransform; +using PJ::sdk::MovingVarianceTransform; +using PJ::sdk::NoneTransform; +using PJ::sdk::OutlierRemovalTransform; +using PJ::sdk::Point2; +using PJ::sdk::SamplesCounterTransform; +using PJ::sdk::ScaleTransform; +using PJ::sdk::TimeSincePreviousTransform; + +// Read-only timestamp/value view over one input series — used only by the +// dialog's preview / clipboard path. Plugin-internal; never crosses the SDK +// boundary, so it lives next to its only consumer instead of in a header. +struct SeriesAccessor { + std::vector timestamps; + std::vector values; + [[nodiscard]] std::size_t size() const { + return timestamps.size(); + } + [[nodiscard]] bool empty() const { + return timestamps.empty(); + } +}; + +constexpr size_t kPreviewMaxPoints = 2000; + +// Process-global "filter clipboard". +std::string& transformClipboard() { + static std::string clipboard; + return clipboard; +} + +// Apply a FilterTransform over a SeriesAccessor, returning the output points. +std::string evaluate(FilterTransform& transform, const SeriesAccessor& input, std::vector& out) { + std::vector in; + in.reserve(input.size()); + for (size_t i = 0; i < input.size(); ++i) { + in.push_back({input.timestamps[i], input.values[i]}); + } + out = transform.applyBatch(in); + return ""; +} + +// Read a numeric series's values as double, dispatching on its primitive type +// (the SDK only exposes typed accessors). Returns false for non-numeric +// (string/bool/unspecified) columns. PJ3 transforms operate on doubles, so we +// widen every integer/float column up front. +bool readValuesAsDouble(const PJ::sdk::MaterializedSeriesView& series, std::vector& out) { + const size_t n = series.timestamps().size(); + out.assign(n, 0.0); + auto copy = [&](const auto* ptr) -> bool { + if (ptr == nullptr) { + return false; + } + for (size_t i = 0; i < n; ++i) { + out[i] = static_cast(ptr[i]); + } + return true; + }; + using PT = PJ::PrimitiveType; + switch (series.type()) { + case PT::kFloat64: + return copy(series.valuesAsFloat64()); + case PT::kFloat32: + return copy(series.valuesAsFloat32()); + case PT::kInt8: + return copy(series.valuesAsInt8()); + case PT::kInt16: + return copy(series.valuesAsInt16()); + case PT::kInt32: + return copy(series.valuesAsInt32()); + case PT::kInt64: + return copy(series.valuesAsInt64()); + case PT::kUint8: + return copy(series.valuesAsUint8()); + case PT::kUint16: + return copy(series.valuesAsUint16()); + case PT::kUint32: + return copy(series.valuesAsUint32()); + case PT::kUint64: + return copy(series.valuesAsUint64()); + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// FilterEditorDialog +// --------------------------------------------------------------------------- + +class FilterEditorDialog : public PJ::DialogPluginTyped { + public: + using ApplyFn = std::function; + + // Wire the host's filter registry. Set by the toolbox after bind() so the + // dialog reaches the SAME factory the host's read path uses (preview and + // streaming render share one source of truth). + void setRegistry(FilterRegistryView view) { + registry_view_ = view; + } + + std::string manifest() const override { + return kFilterEditorManifest; + } + std::string ui_content() const override { + return kFilterEditorDialogUi; + } + + std::string widget_data() override { + PJ::WidgetData wd; + + wd.setListItems("series_list", available_series_); + if (!plot_colors_.empty()) { + std::vector colors; + colors.reserve(available_series_.size()); + for (const auto& name : available_series_) { + auto it = plot_colors_.find(name); + colors.push_back((it != plot_colors_.end()) ? it->second : std::string{}); + } + wd.setListItemColors("series_list", colors); + } + if (!selected_series_list_.empty()) { + wd.setSelectedItems("series_list", selected_series_list_); + } + + // Transform list — built from the factory registry (no hardcoded enum). + { + const auto ids = registry_view_.registeredIds(); + std::vector labels; + labels.reserve(ids.size()); + std::string selected_label = transform_ ? std::string(transform_->label()) : "-- No Transform --"; + for (const auto& tid : ids) { + auto t = registry_view_.create(tid); + if (t) { + labels.push_back(t->label()); + } + } + wd.setListItems("transform_list", labels); + wd.setSelectedItems("transform_list", {selected_label}); + } + + // Show only the relevant parameter panel. + const std::string tid = transform_ ? std::string(transform_->id()) : "none"; + wd.setVisible("panel_scale", tid == "scale"); + wd.setVisible("panel_dt", tid == "derivative" || tid == "integral"); + wd.setVisible("panel_window", tid == "moving_average" || tid == "moving_rms" || tid == "moving_variance"); + wd.setVisible("center_check", tid == "moving_average"); + wd.setVisible("stddev_check", tid == "moving_variance"); + wd.setVisible("stddevHint", tid == "moving_variance"); + wd.setVisible("windowVarianceRow", tid == "moving_variance"); + wd.setVisible("panel_outlier", tid == "outlier_removal"); + wd.setVisible("panel_samples", tid == "samples_counter"); + wd.setVisible("panel_binary", tid == "binary_filter"); + // bin_b only meaningful for Range mode + { + auto* bf = dynamic_cast(transform_.get()); + wd.setVisible("bin_b", bf != nullptr && bf->op == BinaryOp::kRange); + } + + // Parameter widget values — read from the owning transform class. + if (auto* t = dynamic_cast(transform_.get())) { + wd.setText("scale_value", trimDouble(t->value_scale)); + wd.setText("scale_voffset", trimDouble(t->value_offset)); + wd.setText("scale_toffset", trimDouble(t->time_offset)); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setChecked("dt_actual", !t->use_custom_dt); + wd.setChecked("dt_custom", t->use_custom_dt); + wd.setText("dt_value", trimDouble(t->custom_dt)); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setChecked("dt_actual", !t->use_custom_dt); + wd.setChecked("dt_custom", t->use_custom_dt); + wd.setText("dt_value", trimDouble(t->custom_dt)); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setValue("window_spin", t->window); + wd.setChecked("center_check", t->compensate_time_offset); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setValue("window_spin", t->window); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setValue("window_spin", t->window); + wd.setChecked("stddev_check", t->std_dev); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setValue("outlier_spin", t->outlier_factor); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setValue("samples_spin", t->samples_ms); + } + if (auto* t = dynamic_cast(transform_.get())) { + wd.setChecked("bin_eq", t->op == BinaryOp::kEqual); + wd.setChecked("bin_lt", t->op == BinaryOp::kLess); + wd.setChecked("bin_le", t->op == BinaryOp::kLessEq); + wd.setChecked("bin_gt", t->op == BinaryOp::kGreater); + wd.setChecked("bin_ge", t->op == BinaryOp::kGreaterEq); + wd.setChecked("bin_range", t->op == BinaryOp::kRange); + wd.setText("bin_a", trimDouble(t->a)); + wd.setText("bin_b", trimDouble(t->b)); + } + + // Alias field: auto-fill with "source[TransformName]" when the user has + // not manually edited it (same behaviour as PJ3 lineEditAlias). + const bool has_transform = (transform_ && std::string(transform_->id()) != "none"); + wd.setEnabled("alias_edit", has_transform && !selected_series_list_.empty()); + if (!alias_edited_by_user_ || alias_.empty()) { + alias_ = has_transform ? (primarySeries() + "[" + transform_->bracketLabel() + "]") : std::string{}; + } + wd.setText("alias_edit", alias_); + + wd.setChecked("autozoom_check", autozoom_); + wd.setText("status_label", status_msg_); + wd.setEnabled("save_btn", isValid()); + wd.setEnabled("generate_btn", isValid()); + wd.setEnabled("paste_btn", !transformClipboard().empty()); + + wd.setChartZoomEnabled("chart_preview", !autozoom_); + wd.setChartSeries("chart_preview", computePreview()); + + if (!pending_close_.empty()) { + const std::string reason = pending_close_; + pending_close_.clear(); + wd.requestClose(reason); + } + return wd.toJson(); + } + + bool onSelectionChanged(std::string_view name, const std::vector& items) override { + if (name == "series_list" && !items.empty()) { + selected_series_list_ = items; + alias_edited_by_user_ = false; // reset alias when source changes + preview_dirty_ = true; + return true; + } + if (name == "transform_list" && !items.empty()) { + const auto ids = registry_view_.registeredIds(); + for (const auto& tid : ids) { + auto t = registry_view_.create(tid); + if (t && std::string(t->label()) == items.front()) { + transform_ = std::move(t); + break; + } + } + alias_edited_by_user_ = false; + preview_dirty_ = true; + return true; + } + return false; + } + + bool onTextChanged(std::string_view name, std::string_view text) override { + const std::string value(text); + if (name == "alias_edit") { + alias_ = value; + alias_edited_by_user_ = true; + return true; + } + if (name == "scale_value") { + if (auto* t = dynamic_cast(transform_.get())) { + t->value_scale = toDouble(value, 1.0); + } + preview_dirty_ = true; + return true; + } + if (name == "scale_voffset") { + if (auto* t = dynamic_cast(transform_.get())) { + t->value_offset = toDouble(value, 0.0); + } + preview_dirty_ = true; + return true; + } + if (name == "scale_toffset") { + if (auto* t = dynamic_cast(transform_.get())) { + t->time_offset = toDouble(value, 0.0); + } + preview_dirty_ = true; + return true; + } + if (name == "dt_value") { + if (auto* td = dynamic_cast(transform_.get())) { + td->custom_dt = toDouble(value, 0.0); + } + if (auto* ti = dynamic_cast(transform_.get())) { + ti->custom_dt = toDouble(value, 0.0); + } + preview_dirty_ = true; + return true; + } + if (name == "bin_a") { + if (auto* t = dynamic_cast(transform_.get())) { + t->a = toDouble(value, 0.0); + } + preview_dirty_ = true; + return true; + } + if (name == "bin_b") { + if (auto* t = dynamic_cast(transform_.get())) { + t->b = toDouble(value, 0.0); + } + preview_dirty_ = true; + return true; + } + return false; + } + + bool onValueChanged(std::string_view name, int value) override { + if (name == "window_spin") { + if (auto* t = dynamic_cast(transform_.get())) { + t->window = value; + } + if (auto* t = dynamic_cast(transform_.get())) { + t->window = value; + } + if (auto* t = dynamic_cast(transform_.get())) { + t->window = value; + } + preview_dirty_ = true; + return true; + } + if (name == "samples_spin") { + if (auto* t = dynamic_cast(transform_.get())) { + t->samples_ms = value; + } + preview_dirty_ = true; + return true; + } + return false; + } + + bool onValueChanged(std::string_view name, double value) override { + if (name == "outlier_spin") { + if (auto* t = dynamic_cast(transform_.get())) { + t->outlier_factor = value; + } + preview_dirty_ = true; + return true; + } + return false; + } + + bool onToggled(std::string_view name, bool checked) override { + if (name == "autozoom_check") { + autozoom_ = checked; + return true; + } + if (name == "dt_actual" && checked) { + if (auto* td = dynamic_cast(transform_.get())) { + td->use_custom_dt = false; + } + if (auto* ti = dynamic_cast(transform_.get())) { + ti->use_custom_dt = false; + } + preview_dirty_ = true; + return true; + } + if (name == "dt_custom" && checked) { + if (auto* td = dynamic_cast(transform_.get())) { + td->use_custom_dt = true; + } + if (auto* ti = dynamic_cast(transform_.get())) { + ti->use_custom_dt = true; + } + preview_dirty_ = true; + return true; + } + if (name == "center_check") { + if (auto* t = dynamic_cast(transform_.get())) { + t->compensate_time_offset = checked; + } + preview_dirty_ = true; + return true; + } + if (name == "stddev_check") { + if (auto* t = dynamic_cast(transform_.get())) { + t->std_dev = checked; + } + preview_dirty_ = true; + return true; + } + if (checked) { + static const std::unordered_map kOps = { + {"bin_eq", BinaryOp::kEqual}, {"bin_lt", BinaryOp::kLess}, {"bin_le", BinaryOp::kLessEq}, + {"bin_gt", BinaryOp::kGreater}, {"bin_ge", BinaryOp::kGreaterEq}, {"bin_range", BinaryOp::kRange}, + }; + if (auto it = kOps.find(std::string(name)); it != kOps.end()) { + if (auto* t = dynamic_cast(transform_.get())) { + t->op = it->second; + } + preview_dirty_ = true; + return true; + } + } + return false; + } + + bool onClicked(std::string_view name) override { + if (name == "scale_deg2rad") { // objectName kept; button now says "Rad to Deg" + if (auto* t = dynamic_cast(transform_.get())) { + t->value_scale = 180.0 / 3.14159265358979; + } + preview_dirty_ = true; + return true; + } + if (name == "scale_rad2deg") { // objectName kept; button now says "Deg to Rad" + if (auto* t = dynamic_cast(transform_.get())) { + t->value_scale = 3.14159265358979 / 180.0; + } + preview_dirty_ = true; + return true; + } + if (name == "dt_guess") { + // Guess dT: estimate the median time delta from the primary series, + // filtering outliers (same intent as PJ3's buttonCompute). + const std::string primary = primarySeries(); + auto it = series_data_.find(primary); + if (it != series_data_.end() && it->second.size() >= 2) { + const auto& ts = it->second.timestamps; + std::vector deltas; + deltas.reserve(ts.size() - 1); + for (size_t i = 1; i < ts.size(); ++i) { + deltas.push_back(ts[i] - ts[i - 1]); + } + std::sort(deltas.begin(), deltas.end()); + const double median = deltas[deltas.size() / 2]; + if (median > 0.0) { + if (auto* td = dynamic_cast(transform_.get())) { + td->custom_dt = median; + } + if (auto* ti = dynamic_cast(transform_.get())) { + ti->custom_dt = median; + } + preview_dirty_ = true; + } + } + return true; + } + if (name == "save_btn" && isValid()) { + status_msg_ = save_fn_ ? save_fn_() : std::string("Save unavailable: toolbox not bound"); + pending_close_ = "saved"; + return true; + } + if (name == "copy_btn") { + copyRecipe(); + status_msg_ = "Filter copied to clipboard"; + return true; + } + if (name == "paste_btn") { + status_msg_ = pasteRecipe() ? "Filter pasted from clipboard" : "Clipboard is empty"; + return true; + } + if (name == "reset_btn") { + transform_ = std::make_shared(); + preview_dirty_ = true; + status_msg_ = reset_fn_ ? reset_fn_() : std::string("Reset"); + return true; + } + if (name == "generate_btn" && isValid()) { + // Generate time series: apply the filter and write the result to the + // datastore so it appears as a new persistent series in the curve tree. + // Unlike Save (volatile), this creates a real Topic that survives the + // toolbox panel closing. + status_msg_ = generate_fn_ ? generate_fn_() : std::string("Generate unavailable: toolbox not bound"); + return true; + } + if (name == "cancel_btn") { + if (cancel_fn_) { + cancel_fn_(); + } + pending_close_ = "cancelled"; + return true; + } + return false; + } + + // Serialize the current filter recipe (transform + params + code, WITHOUT the + // source binding) into the process-global clipboard. + [[nodiscard]] bool isValid() const { + return !selected_series_list_.empty() && transform_ && std::string(transform_->id()) != "none"; + } + + void setSaveCallback(ApplyFn fn) { + save_fn_ = std::move(fn); + } + void setCancelCallback(std::function fn) { + cancel_fn_ = std::move(fn); + } + void setResetCallback(ApplyFn fn) { + reset_fn_ = std::move(fn); + } + void setGenerateCallback(ApplyFn fn) { + generate_fn_ = std::move(fn); + } + void setStatus(std::string msg) { + status_msg_ = std::move(msg); + } + + void resetToNoTransform() { + transform_ = std::make_shared(); + alias_.clear(); + alias_edited_by_user_ = false; + preview_dirty_ = true; + status_msg_.clear(); + } + + void setAvailableSeries(std::vector names, std::unordered_map data) { + series_data_ = std::move(data); + if (!plot_curves_.empty()) { + available_series_.clear(); + for (const auto& name : names) { + if (std::find(plot_curves_.begin(), plot_curves_.end(), name) != plot_curves_.end()) { + available_series_.push_back(name); + } + } + } else { + available_series_ = std::move(names); + } + std::string primary; + for (const auto& s : selected_series_list_) { + if (series_data_.find(s) != series_data_.end()) { + primary = s; + break; + } + } + if (primary.empty()) { + for (const auto& name : available_series_) { + if (series_data_.find(name) != series_data_.end()) { + primary = name; + break; + } + } + } + selected_series_list_ = primary.empty() ? std::vector{} : std::vector{primary}; + preview_dirty_ = true; + } + + void copyRecipe() const { + auto j = nlohmann::json::parse(saveConfig(), nullptr, false); + if (j.is_discarded()) { + return; + } + j.erase("sources"); + j.erase("source"); + transformClipboard() = j.dump(); + } + + // Load a copied recipe onto the currently-selected sources. The recipe has no + // "sources" key, so the current binding is preserved. + bool pasteRecipe() { + if (transformClipboard().empty()) { + return false; + } + const auto keep_sources = selected_series_list_; + loadConfig(transformClipboard()); + selected_series_list_ = keep_sources; + preview_dirty_ = true; + return true; + } + + std::string saveConfig() const override { + nlohmann::json j; + // "sources" is the canonical key (multi-selection); "source" is kept for + // backward compatibility with single-selection configs. + j["sources"] = selected_series_list_; + if (selected_series_list_.size() == 1) { + j["source"] = selected_series_list_.front(); + } + j["transform"] = transform_ ? std::string(transform_->id()) : "none"; + if (transform_) { + auto ps = nlohmann::json::parse(transform_->saveParams(), nullptr, false); + if (!ps.is_discarded()) { + j["transform_params"] = ps; + } + } + j["alias"] = alias_; + j["alias_edited"] = alias_edited_by_user_; + j["autozoom"] = autozoom_; + // params now persisted per-transform via transform_->saveParams() + return j.dump(); + } + + bool loadConfig(std::string_view config_json) override { + auto j = nlohmann::json::parse(config_json, nullptr, false); + if (j.is_discarded()) { + return false; + } + // __plot_curves is runtime context injected by the host at launch time with + // the curves from the specific plot that was right-clicked. It is NOT part + // of the persistent config and must never be written back by saveConfig(). + if (j.contains("__plot_curves") && j["__plot_curves"].is_array()) { + plot_curves_.clear(); + for (const auto& v : j["__plot_curves"]) { + if (v.is_string()) { + plot_curves_.push_back(v.get()); + } + } + } + if (j.contains("__plot_colors") && j["__plot_colors"].is_object()) { + plot_colors_.clear(); + for (const auto& [name, color] : j["__plot_colors"].items()) { + if (color.is_string()) { + plot_colors_[name] = color.get(); + } + } + } + // Read multi-selection ("sources") or fall back to single ("source"). + selected_series_list_.clear(); + if (j.contains("sources") && j["sources"].is_array()) { + for (const auto& v : j["sources"]) { + if (v.is_string()) { + selected_series_list_.push_back(v.get()); + } + } + } else { + const auto s = j.value("source", std::string{}); + if (!s.empty()) { + selected_series_list_.push_back(s); + } + } + { + const std::string tid = j.value("transform", std::string{"none"}); + transform_ = registry_view_.create(tid); + if (!transform_) { + transform_ = std::make_shared(); + } + if (j.contains("transform_params")) { + transform_->loadParams(j["transform_params"].dump()); + } + } + alias_ = j.value("alias", std::string{}); + alias_edited_by_user_ = j.value("alias_edited", false); + autozoom_ = j.value("autozoom", true); + // params restored per-transform via transform_->loadParams() + preview_dirty_ = true; + status_msg_.clear(); + return true; + } + + // Returns all selected series (used by the toolbox to drive multi-apply). + [[nodiscard]] const std::vector& sourceSeriesList() const { + return selected_series_list_; + } + // Primary series for the preview (first selected). + [[nodiscard]] std::string primarySeries() const { + return selected_series_list_.empty() ? std::string{} : selected_series_list_.front(); + } + /// Access the current transform instance (for the toolbox streaming path). + [[nodiscard]] FilterTransform* currentTransform() const { + return transform_.get(); + } + // Auto-derived output name, PJ3-style: "[]". + [[nodiscard]] std::string outputName() const { + if (!transform_ || std::string(transform_->id()) == "none") { + return primarySeries(); + } + return primarySeries() + "[" + transform_->bracketLabel() + "]"; + } + + static double toDouble(const std::string& s, double fallback) { + try { + size_t pos = 0; + double v = std::stod(s, &pos); + return v; + } catch (...) { + return fallback; + } + } + + static std::string trimDouble(double v) { + std::string s = std::to_string(v); + // strip trailing zeros for a tidier line edit + if (s.find('.') != std::string::npos) { + while (!s.empty() && s.back() == '0') { + s.pop_back(); + } + if (!s.empty() && s.back() == '.') { + s.pop_back(); + } + } + return s; + } + + std::vector computePreview() { + if (!preview_dirty_) { + return cached_preview_; + } + preview_dirty_ = false; + cached_preview_.clear(); + + const std::string primary = primarySeries(); + auto it = series_data_.find(primary); + if (it == series_data_.end() || it->second.empty()) { + return cached_preview_; + } + const SeriesAccessor& selected_input = it->second; + + // Use the first timestamp of the selected curve as the common time origin + // so all series in the preview share the same x=0 reference (PJ3 behaviour). + const double t0 = selected_input.timestamps.front(); + + auto to_chart = + [t0](const std::vector& pts, const std::string& label, const std::string& color) -> PJ::ChartSeries { + PJ::ChartSeries cs; + cs.label = label; + cs.color = color; + const size_t step = (pts.size() > kPreviewMaxPoints) ? (pts.size() / kPreviewMaxPoints) : 1; + for (size_t i = 0; i < pts.size(); i += step) { + cs.points.push_back({(pts[i].x - t0) / 1e9, pts[i].y}); + } + return cs; + }; + + // Blend a "#rrggbb" color towards white (factor > 0) or black (factor < 0). + // factor = 0.65 → lighten 65% towards white (dim for context curves). + // factor = -0.5 → darken 50% towards black (for the transform output). + auto blendColor = [](const std::string& hex, float factor) -> std::string { + if (hex.size() < 7 || hex[0] != '#') { + return hex; + } + auto parse = [&](size_t pos) { return static_cast(std::stoul(hex.substr(pos, 2), nullptr, 16)); }; + auto blend = [factor](int ch) -> int { + if (factor >= 0.0f) { + return static_cast(static_cast(ch) + factor * (255.0f - static_cast(ch))); + } + return static_cast(static_cast(ch) * (1.0f + factor)); + }; + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02x%02x%02x", blend(parse(1)), blend(parse(3)), blend(parse(5))); + return std::string(buf); + }; + + auto curveColor = [this](const std::string& name, const std::string& fallback) -> std::string { + auto color_it = plot_colors_.find(name); + return (color_it != plot_colors_.end() && !color_it->second.empty()) ? color_it->second : fallback; + }; + + // Context curves: real color dimmed towards white so they recede visually. + // Curves in selected_series_list_ but not primary are also shown dimmed + // (the user sees which curves will be affected). + const std::vector& context_curves = plot_curves_.empty() ? available_series_ : plot_curves_; + for (const auto& name : context_curves) { + if (name == primary) { + continue; // drawn separately below + } + auto ctx_it = series_data_.find(name); + if (ctx_it == series_data_.end() || ctx_it->second.empty()) { + continue; + } + const SeriesAccessor& ctx = ctx_it->second; + std::vector ctx_pts; + ctx_pts.reserve(ctx.size()); + for (size_t i = 0; i < ctx.size(); ++i) { + ctx_pts.push_back({ctx.timestamps[i], ctx.values[i]}); + } + const std::string dim = blendColor(curveColor(name, "#aaaaaa"), 0.65f); + cached_preview_.push_back(to_chart(ctx_pts, name, dim)); + } + + // Primary selected curve — full real color, drawn on top of context. + std::vector in_pts; + in_pts.reserve(selected_input.size()); + for (size_t i = 0; i < selected_input.size(); ++i) { + in_pts.push_back({selected_input.timestamps[i], selected_input.values[i]}); + } + const std::string source_color = curveColor(primary, "#333333"); + cached_preview_.push_back(to_chart(in_pts, primary, source_color)); + + // Transform output — same color as source but darker, so it reads as + // "this is the same curve, transformed" rather than a foreign overlay. + std::vector output; + if (!transform_ || std::string(transform_->id()) == "none") { + output = in_pts; + } else { + std::string err = evaluate(*transform_, selected_input, output); + if (!err.empty()) { + status_msg_ = "Preview error: " + err; + return cached_preview_; + } + } + status_msg_.clear(); + // Darken the source color for the transform output so it contrasts with + // the original without introducing an unrelated hue. + const std::string transform_color = blendColor(source_color, -0.5f); // negative = darken + cached_preview_.push_back(to_chart(output, outputName(), transform_color)); + + return cached_preview_; + } + + std::vector available_series_; + std::unordered_map series_data_; + std::vector selected_series_list_; // multi-selection + // Curves from the plot that launched this editor (right-click context). + // Empty = show all datastore series (launched from toolbox panel). + std::vector plot_curves_; + // Colors of those curves: human_name -> "#rrggbb". Used in the preview. + std::unordered_map plot_colors_; + // Filter registry exposed by the host. Set by the toolbox after bind() so + // the dialog resolves transforms by id through the same factory the host + // read path uses. Invalid before bind — early code paths defend with NULL + // checks and fall back to local NoneTransform construction. + FilterRegistryView registry_view_; + // Current transform instance (owns its parameters). Never null. + std::shared_ptr transform_ = std::make_shared(); + // Alias: custom legend name for the transformed curve (PJ3 lineEditAlias). + // Auto-filled with "source[TransformName]"; user can override it. + std::string alias_; + bool alias_edited_by_user_ = false; + bool autozoom_ = true; + std::string status_msg_; + + bool preview_dirty_ = true; + std::vector cached_preview_; + ApplyFn save_fn_; + std::function cancel_fn_; + ApplyFn reset_fn_; + ApplyFn generate_fn_; + std::string pending_close_; +}; + +// --------------------------------------------------------------------------- +// FilterEditorToolbox +// --------------------------------------------------------------------------- + +class FilterEditorToolbox : public PJ::ToolboxPluginBase { + public: + uint64_t capabilities() const override { + return PJ::kToolboxCapabilityHasDialog; + } + + PJ::Status bind(PJ::sdk::ServiceRegistry services) override { + auto base = PJ::ToolboxPluginBase::bind(services); + if (!base) { + return base; + } + // The host owns the FilterTransformFactory. We populate it with the 12 + // builtin strategies (the math vendored in the SDK) so preview and the + // host's read path resolve to the same instances. library_owner is empty + // for this v1 cut — the PluginRuntimeCatalog already keeps the toolbox + // DSO loaded while it's a registered toolbox, which is the only window in + // which a created transform can be in flight (no orphaned instances after + // the toolbox unloads). + auto reg = services.require(); + if (!reg) { + return PJ::unexpected(std::move(reg).error()); + } + registry_view_ = *reg; + if (auto e = registry_view_.registerTransform("none", {}); !e) { + return PJ::unexpected(std::move(e).error()); + } + (void)registry_view_.registerTransform("absolute", {}); + (void)registry_view_.registerTransform("scale", {}); + (void)registry_view_.registerTransform("derivative", {}); + (void)registry_view_.registerTransform("integral", {}); + (void)registry_view_.registerTransform("moving_average", {}); + (void)registry_view_.registerTransform("moving_rms", {}); + (void)registry_view_.registerTransform("moving_variance", {}); + (void)registry_view_.registerTransform("outlier_removal", {}); + (void)registry_view_.registerTransform("samples_counter", {}); + (void)registry_view_.registerTransform("binary_filter", {}); + (void)registry_view_.registerTransform("time_since_previous", {}); + dialog_.setRegistry(registry_view_); + return PJ::okStatus(); + } + + PJ_borrowed_dialog_t getDialog() override { + if (toolboxHostBound()) { + std::vector names; + std::unordered_map data; + readCatalog(names, data); + dialog_.setAvailableSeries(std::move(names), std::move(data)); + } + // Snapshot config at open so Cancel can revert the edits. + config_at_open_ = dialog_.saveConfig(); + dialog_.setSaveCallback([this]() { return applyAndReport(); }); + dialog_.setCancelCallback([this]() { dialog_.loadConfig(config_at_open_); }); + dialog_.setResetCallback([this]() { return resetAndReport(); }); + dialog_.setGenerateCallback([this]() { return generateAndReport(); }); + return PJ::borrowDialog(dialog_); + } + + std::string saveConfig() const override { + return dialog_.saveConfig(); + } + + PJ::Status loadConfig(std::string_view config_json) override { + dialog_.loadConfig(config_json); + return PJ::okStatus(); + } + + void onDataChanged() override { + // In the volatile model the transform lives in the DatastoreCurveAdapter + // read path (host side). The host re-applies it on every notifyDataChanged + // call, so the plugin side has nothing to do here — the streaming update + // path is handled by the adapter's onTopicCommitted invalidation. + } + + private: + void readCatalog(std::vector& names, std::unordered_map& data) { + auto host = toolboxHost(); + auto catalog = host.catalogSnapshot(); + if (!catalog) { + return; + } + auto all_fields = catalog->fields(); + for (const auto& topic : catalog->topics()) { + std::string topic_name(PJ::sdk::toStringView(topic.name)); + for (uint32_t fi = topic.first_field; fi < topic.first_field + topic.field_count; ++fi) { + const auto& f = all_fields[fi]; + std::string name = topic_name + "/" + std::string(PJ::sdk::toStringView(f.name)); + names.push_back(name); + auto series = host.readSeries(f.handle); + if (series) { + std::vector values; + if (readValuesAsDouble(*series, values)) { + auto ts = series->timestamps(); + SeriesAccessor acc; + acc.timestamps.resize(ts.size()); + for (size_t i = 0; i < ts.size(); ++i) { + acc.timestamps[i] = static_cast(ts[i]); + } + acc.values = std::move(values); + data[name] = std::move(acc); + } + } + } + } + std::sort(names.begin(), names.end()); + } + + PJ::Expected readSource(const std::string& full_name) { + auto host = toolboxHost(); + auto catalog = host.catalogSnapshot(); + if (!catalog) { + return PJ::unexpected("failed to acquire catalog"); + } + auto all_fields = catalog->fields(); + for (const auto& topic : catalog->topics()) { + std::string topic_name(PJ::sdk::toStringView(topic.name)); + for (uint32_t fi = topic.first_field; fi < topic.first_field + topic.field_count; ++fi) { + const auto& f = all_fields[fi]; + std::string qualified = topic_name + "/" + std::string(PJ::sdk::toStringView(f.name)); + if (qualified != full_name) { + continue; + } + auto series = host.readSeries(f.handle); + if (!series) { + return PJ::unexpected("failed to read series: " + full_name); + } + std::vector values; + if (!readValuesAsDouble(*series, values)) { + return PJ::unexpected("series is not numeric: " + full_name); + } + auto ts = series->timestamps(); + SeriesAccessor acc; + acc.timestamps.resize(ts.size()); + for (size_t i = 0; i < ts.size(); ++i) { + acc.timestamps[i] = static_cast(ts[i]); + } + acc.values = std::move(values); + return acc; + } + } + return PJ::unexpected("series not found: " + full_name); + } + + // Called from the dialog's "Save" button. The filter is applied as a + // volatile transform in the DatastoreCurveAdapter read path (host side), + // so no Topic is written to the datastore. notifyDataChanged() triggers + // the host's on_data_changed callback which builds and installs the + // TransformFn from the current saveConfig() JSON. + std::string applyAndReport() { + if (!runtimeHostBound()) { + return "Toolbox not bound to a host"; + } + created_ = true; + runtimeHost().notifyDataChanged(); + return "Applied '" + dialog_.outputName() + "'"; + } + + // Called from the dialog's "Reset" button. + std::string resetAndReport() { + dialog_.resetToNoTransform(); + created_ = false; + last_applied_config_.clear(); + // Notify the host with "none" transform so it clears the volatile transform + // from the DatastoreCurveAdapter — the original curve is restored instantly. + if (runtimeHostBound()) { + runtimeHost().notifyDataChanged(); + } + return "Reset"; + } + + // Called from the dialog's "Generate time series" button. + // Applies the filter to ALL selected sources and writes each result as a + // new persistent series in the datastore — it appears in the curve tree. + // Unlike Save (volatile), this series survives the panel closing. + std::string generateAndReport() { + if (!toolboxHostBound() || !runtimeHostBound()) { + return "Toolbox not bound to a host"; + } + std::vector created_names; + for (const auto& series_name : dialog_.sourceSeriesList()) { + auto source = readSource(series_name); + if (!source) { + continue; + } + auto* transform = dialog_.currentTransform(); + if (!transform || std::string(transform->id()) == "none") { + continue; + } + + std::vector in, output; + in.reserve(source->size()); + for (size_t i = 0; i < source->size(); ++i) { + in.push_back({source->timestamps[i], source->values[i]}); + } + output = transform->applyBatch(in); + if (output.empty()) { + continue; + } + + const std::string out_name = series_name + "[" + transform->bracketLabel() + "]"; + auto host = toolboxHost(); + auto src = host.createDataSource("filter_editor_generated"); + if (!src) { + continue; + } + auto topic = host.ensureTopic(*src, out_name); + if (!topic) { + continue; + } + for (const auto& pt : output) { + const PJ::sdk::NamedFieldValue fields[] = {{.name = "value", .value = pt.y}}; + (void)host.appendRecord(*topic, static_cast(pt.x), PJ::Span(fields)); + } + created_names.push_back(out_name); + } + runtimeHost().notifyDataChanged(); + if (created_names.empty()) { + return "No series generated"; + } + std::string msg = "Generated: "; + for (size_t i = 0; i < created_names.size(); ++i) { + if (i > 0) { + msg += ", "; + } + msg += "'" + created_names[i] + "'"; + } + return msg; + } + + PJ::Status applyTransformToStore(bool reset_if_changed) { + auto host = toolboxHost(); + + auto source = readSource(dialog_.primarySeries()); + if (!source) { + return PJ::unexpected(source.error()); + } + + // Detect config changes (transform, params, source, output name) and reset + // the append cursor + output handles so a re-create replays from scratch. + std::string config = dialog_.saveConfig(); + if (reset_if_changed && config != last_applied_config_) { + resetIncrementalState(); + } + last_applied_config_ = config; + + const size_t source_count = source->size(); + if (source_count < consumed_input_count_) { + resetIncrementalState(); // reload / replacement shrank the series + } + // "If nothing changes, don't recompute" — no new samples to process. + if (created_ && source_count == consumed_input_count_) { + return PJ::okStatus(); + } + + // Streaming: use the transform's isStreamSafe() to decide whether to + // recompute only the new tail (safe) or the full series (unsafe). + // We always do full-batch via applyBatch for correctness; the incremental + // path (consumed_input_count_) still avoids re-appending old points. + const bool monotonic = dialog_.currentTransform() && dialog_.currentTransform()->isStreamSafe(); + (void)monotonic; // reserved for T3 streaming optimisation + + SeriesAccessor full_src = *source; + + std::vector output; + if (dialog_.currentTransform()) { + std::string err = evaluate(*dialog_.currentTransform(), full_src, output); + if (!err.empty()) { + return PJ::unexpected(err); + } + } + + if (!source_handle_) { + auto src = host.createDataSource("transform_editor"); + if (!src) { + return PJ::unexpected("failed to create output data source"); + } + source_handle_ = *src; + } + if (!topic_handle_) { + auto topic = host.ensureTopic(*source_handle_, dialog_.outputName()); + if (!topic) { + return PJ::unexpected("failed to create output topic"); + } + topic_handle_ = *topic; + } + + auto appendPoint = [&](const Point2& pt) -> PJ::Status { + const PJ::sdk::NamedFieldValue fields[] = { + {.name = "value", .value = pt.y}, + }; + auto st = host.appendRecord( + *topic_handle_, static_cast(pt.x), PJ::Span(fields)); + if (!st) { + return PJ::unexpected("failed to append record: " + std::string(st.error())); + } + return PJ::okStatus(); + }; + + if (monotonic) { + // The suffix overlaps the already-emitted region (look-back context); + // those points carry timestamps <= last_appended_ts_ and are skipped. + for (const auto& pt : output) { + const auto ts = static_cast(pt.x); + if (ts <= last_appended_ts_) { + continue; + } + if (auto st = appendPoint(pt); !st) { + return st; + } + last_appended_ts_ = ts; + } + } else { + // Full recompute: suffix == full input, so output index == global index. + for (size_t i = emitted_output_count_; i < output.size(); ++i) { + if (auto st = appendPoint(output[i]); !st) { + return st; + } + } + emitted_output_count_ = output.size(); + } + consumed_input_count_ = source_count; + runtimeHost().notifyDataChanged(); + return PJ::okStatus(); + } + + void resetIncrementalState() { + source_handle_ = std::nullopt; + topic_handle_ = std::nullopt; + consumed_input_count_ = 0; + last_appended_ts_ = std::numeric_limits::min(); + emitted_output_count_ = 0; + } + + FilterEditorDialog dialog_; + FilterRegistryView registry_view_; + bool created_ = false; + std::string config_at_open_; + std::string last_applied_config_; + std::optional source_handle_; + std::optional topic_handle_; + size_t consumed_input_count_ = 0; + int64_t last_appended_ts_ = std::numeric_limits::min(); + size_t emitted_output_count_ = 0; +}; + +} // namespace + +PJ_TOOLBOX_PLUGIN(FilterEditorToolbox, kFilterEditorManifest) +PJ_DIALOG_PLUGIN(FilterEditorDialog) diff --git a/toolbox_filter_editor/manifest.json b/toolbox_filter_editor/manifest.json new file mode 100644 index 0000000..5051efc --- /dev/null +++ b/toolbox_filter_editor/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "toolbox-filter-editor", + "name": "Filter Editor", + "version": "0.2.0", + "description": "Apply predefined filters (Moving Average, Derivative, Integral, ...) to curves in a plot. Invoked via right-click on a plot or from the toolbox launcher. Port of PlotJuggler 3's 'Apply filter to data' dialog.", + "author": "PlotJuggler Team", + "publisher": "PlotJuggler", + "website": "https://github.com/PlotJuggler/pj-official-plugins", + "repository": "https://github.com/PlotJuggler/pj-official-plugins", + "license": "MPL-2.0", + "icon_url": "", + "category": "toolbox", + "tags": [ + "filter", + "moving-average", + "derivative", + "integral", + "predefined", + "toolbox", + "plot_action" + ], + "min_plotjuggler_version": "4.0.0" +}