From e01297abf62b57562aba29e8a2bd94aa23579d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 03:35:35 +0200 Subject: [PATCH 01/10] feat(toolbox): Filter Editor toolbox plugin PJ3's 'Apply filter to data' as a PJ4 toolbox panel: multi-curve source selection, closed predefined-transform catalogue, live preview, optional alias, copy/paste clipboard. Launches from the plot right-click via the manifest plot_action tag. Strategy pattern across the plugin / SDK boundary: - Contract (plotjuggler_sdk#120 pj_plugins/filter_protocol/): abstract PJ::sdk::FilterTransform + FilterTransformFactory. - Strategies (this plugin, builtin_transforms.hpp): 12 concrete classes deriving from PJ::sdk::FilterTransform; SeriesAccessor for the dialog preview path is plugin-local too. - Registration: registerAllTransforms() at loaderInit. Tests (builtin_transforms_test.cpp): per-transform deterministic-causal + bit-identity. Pure C++ gtest, no Qt. Bumps extern/plotjuggler_core submodule to the contract+tags commit (plotjuggler_sdk#120). --- CMakeLists.txt | 2 + extern/plotjuggler_core | 2 +- toolbox_filter_editor/CMakeLists.txt | 48 + toolbox_filter_editor/builtin_transforms.hpp | 840 +++++++++++++ .../builtin_transforms_test.cpp | 214 ++++ toolbox_filter_editor/conanfile.py | 24 + .../custom_function_engine.hpp | 192 +++ .../custom_function_engine_test.cpp | 140 +++ toolbox_filter_editor/filter_editor_dialog.ui | 339 ++++++ .../filter_editor_plugin.cpp | 1076 +++++++++++++++++ toolbox_filter_editor/manifest.json | 23 + 11 files changed, 2899 insertions(+), 1 deletion(-) create mode 100644 toolbox_filter_editor/CMakeLists.txt create mode 100644 toolbox_filter_editor/builtin_transforms.hpp create mode 100644 toolbox_filter_editor/builtin_transforms_test.cpp create mode 100644 toolbox_filter_editor/conanfile.py create mode 100644 toolbox_filter_editor/custom_function_engine.hpp create mode 100644 toolbox_filter_editor/custom_function_engine_test.cpp create mode 100644 toolbox_filter_editor/filter_editor_dialog.ui create mode 100644 toolbox_filter_editor/filter_editor_plugin.cpp create mode 100644 toolbox_filter_editor/manifest.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a41ed8..121660a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,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() @@ -151,5 +152,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..df6b7b1 --- /dev/null +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -0,0 +1,840 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: MPL-2.0 + +// Built-in Filter Transform catalogue — concrete classes that ship with the +// Filter Editor toolbox. Each class implements the abstract +// PJ::sdk::FilterTransform contract defined in the SDK +// (plotjuggler_sdk/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp). +// +// The classes mirror PJ3's TransformFunction_SISO catalogue: each one owns its +// own parameters, implements calculateNextPoint() for SISO streaming, persists +// itself via saveParams() / loadParams(), and clones via clone(). Pure C++20 — +// no Qt, no Lua, no datastore — fully unit-testable in isolation. +// +// Registration: the plugin's init code calls registerAllTransforms( +// PJ::sdk::FilterTransformFactory::instance()) at load, so the host (PJ4 app) +// can later look the transforms up by id and obtain instances without knowing +// the catalogue at compile time. A future plugin can add its own classes the +// same way without touching the SDK. + +#include +#include +#include +#include +#include +#include +#include +#include + +// nlohmann/json is required for per-transform parameter persistence. The +// builtin_transforms_test below links against gtest only; guard the JSON +// include so the classes can be exercised through the computation API without +// the persistence path. +#ifdef NLOHMANN_JSON_HPP +#define PJ_TRANSFORM_HAS_JSON 1 +#else +#ifdef __has_include +#if __has_include() +#include +#define PJ_TRANSFORM_HAS_JSON 1 +#endif +#endif +#endif + +#include "pj_plugins/sdk/filter_transform.hpp" +#include "pj_plugins/sdk/filter_transform_factory.hpp" + +namespace PJ::sdk { + +// --------------------------------------------------------------------------- +// Concrete transforms +// --------------------------------------------------------------------------- + +// --- None (passthrough) --- + +class NoneTransform : public FilterTransform { + public: + const char* id() const override { + return "none"; + } + const char* label() const override { + return "-- No Transform --"; + } + const char* bracketLabel() const override { + return "copy"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return in; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Absolute --- + +class AbsoluteTransform : public FilterTransform { + public: + const char* id() const override { + return "absolute"; + } + const char* label() const override { + return "Absolute"; + } + const char* bracketLabel() const override { + return "Absolute"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return Point2{in.x, std::abs(in.y)}; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Scale / Offset --- + +class ScaleTransform : public FilterTransform { + public: + double value_scale = 1.0; + double value_offset = 0.0; + double time_offset = 0.0; + + const char* id() const override { + return "scale"; + } + const char* label() const override { + return "Scale/Offset"; + } + const char* bracketLabel() const override { + return "Scale"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + std::optional calculateNextPoint(const Point2& in) override { + return Point2{in.x + time_offset, value_scale * in.y + value_offset}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["value_scale"] = value_scale; + j["value_offset"] = value_offset; + j["time_offset"] = time_offset; + return j.dump(); +#else + return "{}"; +#endif + } + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + value_scale = j.value("value_scale", 1.0); + value_offset = j.value("value_offset", 0.0); + time_offset = j.value("time_offset", 0.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Derivative --- + +class DerivativeTransform : public FilterTransform { + public: + bool use_custom_dt = false; + double custom_dt = 1.0; + + const char* id() const override { + return "derivative"; + } + const char* label() const override { + return "Derivative"; + } + const char* bracketLabel() const override { + return "Derivative"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + prev_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_.has_value()) { + prev_ = in; + return std::nullopt; + } + const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); + if (dt <= 0.0) { + prev_ = in; + return std::nullopt; + } + const Point2 out{prev_->x, (in.y - prev_->y) / dt}; + prev_ = in; + return out; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["use_custom_dt"] = use_custom_dt; + j["custom_dt"] = custom_dt; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + use_custom_dt = j.value("use_custom_dt", false); + custom_dt = j.value("custom_dt", 1.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::optional prev_; +}; + +// --- Integral (trapezoid) --- + +class IntegralTransform : public FilterTransform { + public: + bool use_custom_dt = false; + double custom_dt = 1.0; + + const char* id() const override { + return "integral"; + } + const char* label() const override { + return "Integral"; + } + const char* bracketLabel() const override { + return "Integral"; + } + // Unbounded running accumulator — correct only when all prior points + // have been processed in order from the start; not incrementally safe. + bool isStreamSafe() const override { + return false; + } + + void reset() override { + acc_ = 0.0; + prev_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_.has_value()) { + prev_ = in; + return std::nullopt; + } + const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); + if (dt > 0.0) { + acc_ += (in.y + prev_->y) * dt / 2.0; + } + const Point2 out{in.x, acc_}; + prev_ = in; + return out; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["use_custom_dt"] = use_custom_dt; + j["custom_dt"] = custom_dt; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + use_custom_dt = j.value("use_custom_dt", false); + custom_dt = j.value("custom_dt", 1.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + double acc_ = 0.0; + std::optional prev_; +}; + +// --- Moving Average --- + +class MovingAverageTransform : public FilterTransform { + public: + int window = 10; + bool compensate_time_offset = false; + + const char* id() const override { + return "moving_average"; + } + const char* label() const override { + return "Moving Average"; + } + const char* bracketLabel() const override { + return "Moving Average"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + // Pad underfull window with current point (PJ3 semantics) + double total = in.y * static_cast(w - buf_.size()); + for (const auto& p : buf_) { + total += p.y; + } + double t = in.x; + if (compensate_time_offset && buf_.size() > 1) { + t = (buf_.front().x + buf_.back().x) / 2.0; + } + return Point2{t, total / static_cast(w)}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + j["compensate_time_offset"] = compensate_time_offset; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); + compensate_time_offset = j.value("compensate_time_offset", false); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Moving RMS --- + +class MovingRMSTransform : public FilterTransform { + public: + int window = 10; + + const char* id() const override { + return "moving_rms"; + } + const char* label() const override { + return "Moving Root Mean Squared"; + } + const char* bracketLabel() const override { + return "Moving Root Mean Squared"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + double total_sqr = in.y * in.y * static_cast(w - buf_.size()); + for (const auto& p : buf_) { + total_sqr += p.y * p.y; + } + return Point2{in.x, std::sqrt(total_sqr / static_cast(w))}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Moving Variance / Stdev --- + +class MovingVarianceTransform : public FilterTransform { + public: + int window = 10; + bool std_dev = false; // true → output sqrt(variance) + + const char* id() const override { + return "moving_variance"; + } + const char* label() const override { + return "Moving Variance / Stdev"; + } + const char* bracketLabel() const override { + return "Moving Variance / Stdev"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + const size_t w = static_cast(std::max(1, window)); + while (buf_.size() > w) { + buf_.erase(buf_.begin()); + } + const double pad = static_cast(w - buf_.size()); + double total = in.y * pad; + for (const auto& p : buf_) { + total += p.y; + } + const double avg = total / static_cast(w); + double total_sqr = (in.y - avg) * (in.y - avg) * pad; + for (const auto& p : buf_) { + const double v = p.y - avg; + total_sqr += v * v; + } + const double var = total_sqr / static_cast(w); + return Point2{in.x, std_dev ? std::sqrt(var) : var}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["window"] = window; + j["std_dev"] = std_dev; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + window = j.value("window", 10); + std_dev = j.value("std_dev", false); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Outlier Removal --- + +class OutlierRemovalTransform : public FilterTransform { + public: + double outlier_factor = 100.0; + + const char* id() const override { + return "outlier_removal"; + } + const char* label() const override { + return "Outlier Removal"; + } + const char* bracketLabel() const override { + return "Outlier Removal"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + buf_.clear(); + } + + // Overrides applyBatch: needs 4-sample look-ahead ring (PJ3 semantics). + std::vector applyBatch(const std::vector& input) override { + const size_t n = input.size(); + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + if (i < 3) { + out.push_back(input[i]); + continue; + } + const double d1 = input[i - 2].y - input[i - 1].y; + const double d2 = input[i - 1].y - input[i].y; + bool drop = false; + if (d1 * d2 < 0) { + const double d0 = input[i - 3].y - input[i - 2].y; + const double jump = std::max(std::abs(d1), std::abs(d2)); + const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); + if (ratio > outlier_factor) { + drop = true; + } + } + if (!drop) { + out.push_back({input[i - 1].x, input[i - 1].y}); + } + } + return out; + } + + // calculateNextPoint not used (applyBatch overridden), but required by interface. + std::optional calculateNextPoint(const Point2& in) override { + buf_.push_back(in); + if (buf_.size() < 4) { + return in; + } + const size_t i = buf_.size() - 1; + const double d1 = buf_[i - 2].y - buf_[i - 1].y; + const double d2 = buf_[i - 1].y - buf_[i].y; + if (d1 * d2 < 0) { + const double d0 = buf_[i - 3].y - buf_[i - 2].y; + const double jump = std::max(std::abs(d1), std::abs(d2)); + const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); + if (ratio > outlier_factor) { + return std::nullopt; + } + } + return Point2{buf_[i - 1].x, buf_[i - 1].y}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["outlier_factor"] = outlier_factor; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + outlier_factor = j.value("outlier_factor", 100.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector buf_; +}; + +// --- Samples Counter --- + +class SamplesCounterTransform : public FilterTransform { + public: + int samples_ms = 1000; + + const char* id() const override { + return "samples_counter"; + } + const char* label() const override { + return "Samples Counter"; + } + const char* bracketLabel() const override { + return "Samples Counter"; + } + // Time-windowed look-back: needs all prior points in the window. + bool isStreamSafe() const override { + return false; + } + + void reset() override { + all_.clear(); + } + + // Overrides applyBatch for correct time-window semantics. + std::vector applyBatch(const std::vector& input) override { + const size_t n = input.size(); + const double delta = 0.001 * static_cast(samples_ms); + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + const double min_t = input[i].x - delta; + size_t lo = 0, hi = i + 1; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (input[mid].x < min_t) { + lo = mid + 1; + } else { + hi = mid; + } + } + out.push_back({input[i].x, static_cast(i - lo)}); + } + return out; + } + + std::optional calculateNextPoint(const Point2& in) override { + all_.push_back(in); + const double delta = 0.001 * static_cast(samples_ms); + const double min_t = in.x - delta; + size_t count = 0; + for (size_t i = all_.size(); i-- > 0;) { + if (all_[i].x < min_t) { + break; + } + ++count; + } + return Point2{in.x, static_cast(count - 1)}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["samples_ms"] = samples_ms; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + samples_ms = j.value("samples_ms", 1000); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::vector all_; +}; + +// --- Binary Filter --- + +enum class BinaryOp { kEqual, kLess, kLessEq, kGreater, kGreaterEq, kRange }; + +class BinaryFilterTransform : public FilterTransform { + public: + BinaryOp op = BinaryOp::kGreater; + double a = 0.0; + double b = 0.0; + + const char* id() const override { + return "binary_filter"; + } + const char* label() const override { + return "Binary Filter"; + } + const char* bracketLabel() const override { + return "Binary Filter"; + } + bool isStreamSafe() const override { + return true; + } + void reset() override {} + + std::optional calculateNextPoint(const Point2& in) override { + double r = 0.0; + switch (op) { + case BinaryOp::kEqual: + r = (std::abs(in.y - a) <= 1e-9 * std::max(1.0, std::abs(a))) ? 1.0 : 0.0; + break; + case BinaryOp::kLess: + r = (in.y < a) ? 1.0 : 0.0; + break; + case BinaryOp::kLessEq: + r = (in.y <= a) ? 1.0 : 0.0; + break; + case BinaryOp::kGreater: + r = (in.y > a) ? 1.0 : 0.0; + break; + case BinaryOp::kGreaterEq: + r = (in.y >= a) ? 1.0 : 0.0; + break; + case BinaryOp::kRange: { + const double lo = std::min(a, b), hi = std::max(a, b); + r = (in.y >= lo && in.y <= hi) ? 1.0 : 0.0; + break; + } + } + return Point2{in.x, r}; + } + std::string saveParams() const override { +#ifdef PJ_TRANSFORM_HAS_JSON + nlohmann::json j; + j["binary_op"] = static_cast(op); + j["binary_a"] = a; + j["binary_b"] = b; + return j.dump(); +#else + return "{}"; +#endif + } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" + void loadParams(const std::string& json_str) override { + (void)json_str; // suppress unused-param in non-json build +#ifdef PJ_TRANSFORM_HAS_JSON + auto j = nlohmann::json::parse(json_str, nullptr, false); + if (j.is_discarded()) { + return; + } + op = static_cast(j.value("binary_op", static_cast(BinaryOp::kGreater))); + a = j.value("binary_a", 0.0); + b = j.value("binary_b", 0.0); +#endif + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } +}; + +// --- Time Since Previous Point2 --- + +class TimeSincePreviousTransform : public FilterTransform { + public: + const char* id() const override { + return "time_since_previous"; + } + const char* label() const override { + return "Time Since Previous Point2"; + } + const char* bracketLabel() const override { + return "Time Since Previous Point2"; + } + bool isStreamSafe() const override { + return true; + } + + void reset() override { + prev_t_ = std::nullopt; + } + + std::optional calculateNextPoint(const Point2& in) override { + if (!prev_t_.has_value()) { + prev_t_ = in.x; + return std::nullopt; + } + const Point2 out{in.x, in.x - *prev_t_}; + prev_t_ = in.x; + return out; + } + std::unique_ptr clone() const override { + return std::make_unique(*this); + } + + private: + std::optional prev_t_; +}; + +// --------------------------------------------------------------------------- +// Registration — all transforms in display order +// --------------------------------------------------------------------------- + +inline void registerAllTransforms() { + auto& f = FilterTransformFactory::instance(); + // Guard: only register once. + static bool registered = false; + if (registered) { + return; + } + registered = true; + + f.registerTransform(NoneTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(AbsoluteTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(ScaleTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(DerivativeTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(IntegralTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingAverageTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingRMSTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(MovingVarianceTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(OutlierRemovalTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(SamplesCounterTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(BinaryFilterTransform{}.id(), [] { return std::make_unique(); }); + f.registerTransform(TimeSincePreviousTransform{}.id(), [] { return std::make_unique(); }); +} + +} // namespace PJ::sdk 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..bcc3c21 --- /dev/null +++ b/toolbox_filter_editor/custom_function_engine.hpp @@ -0,0 +1,192 @@ +#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..be1e340 --- /dev/null +++ b/toolbox_filter_editor/filter_editor_dialog.ui @@ -0,0 +1,339 @@ + + + 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 + + + + + + diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp new file mode 100644 index 0000000..4e0b37d --- /dev/null +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -0,0 +1,1076 @@ +// 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" + +namespace { + +using PJ::sdk::BinaryFilterTransform; +using PJ::sdk::BinaryOp; +using PJ::sdk::DerivativeTransform; +using PJ::sdk::FilterTransform; +using PJ::sdk::FilterTransformFactory; +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::registerAllTransforms; +using PJ::sdk::SamplesCounterTransform; +using PJ::sdk::ScaleTransform; +using PJ::sdk::SeriesAccessor; +using PJ::sdk::TimeSincePreviousTransform; + +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; + + 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 = FilterTransformFactory::instance().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 = FilterTransformFactory::instance().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("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 = FilterTransformFactory::instance().registeredIds(); + for (const auto& tid : ids) { + auto t = FilterTransformFactory::instance().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_unique(); + preview_dirty_ = true; + status_msg_ = reset_fn_ ? reset_fn_() : std::string("Reset"); + 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 setStatus(std::string msg) { + status_msg_ = std::move(msg); + } + + void resetToNoTransform() { + transform_ = std::make_unique(); + 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_ = FilterTransformFactory::instance().create(tid); + if (!transform_) { + transform_ = std::make_unique(); + } + 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_; + // Current transform instance (owns its parameters). Never null. + std::unique_ptr transform_ = std::make_unique(); + // 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_; + std::string pending_close_; +}; + +// --------------------------------------------------------------------------- +// FilterEditorToolbox +// --------------------------------------------------------------------------- + +class FilterEditorToolbox : public PJ::ToolboxPluginBase { + public: + uint64_t capabilities() const override { + return PJ::kToolboxCapabilityHasDialog; + } + + PJ_borrowed_dialog_t getDialog() override { + // Ensure all transforms are registered before the dialog is shown. + registerAllTransforms(); + 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(); }); + 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"; + } + + 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_; + 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" +} From bc666e35f5a4b50cf5baa05bdc91e2e9be018ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 03:38:09 +0200 Subject: [PATCH 02/10] style(toolbox_filter_editor): clang-format custom_function_engine.hpp --- toolbox_filter_editor/custom_function_engine.hpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/toolbox_filter_editor/custom_function_engine.hpp b/toolbox_filter_editor/custom_function_engine.hpp index bcc3c21..eab6ecb 100644 --- a/toolbox_filter_editor/custom_function_engine.hpp +++ b/toolbox_filter_editor/custom_function_engine.hpp @@ -118,9 +118,7 @@ class CustomFunctionEngine { /// 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, + const SeriesAccessor& main, const std::vector& additional, double after_timestamp, std::vector& out) { if (!calc_.valid()) { return "internal error: function not compiled"; From f0cb77aac53fdf93c8ea5e9c4d8b2c09cc0be571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 03:45:48 +0200 Subject: [PATCH 03/10] fix(toolbox_filter_editor): qualify SeriesAccessor in pj_custom_function The dialog uses pj_custom_function::SeriesAccessor for its preview path, not a SDK contract type. Include the engine header and drop the stale PJ::sdk:: qualifier. --- toolbox_filter_editor/filter_editor_plugin.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index 4e0b37d..2cee93c 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -22,6 +22,7 @@ #include #include "builtin_transforms.hpp" +#include "custom_function_engine.hpp" #include "filter_editor_dialog_ui.hpp" #include "filter_editor_manifest.hpp" @@ -42,8 +43,8 @@ using PJ::sdk::Point2; using PJ::sdk::registerAllTransforms; using PJ::sdk::SamplesCounterTransform; using PJ::sdk::ScaleTransform; -using PJ::sdk::SeriesAccessor; using PJ::sdk::TimeSincePreviousTransform; +using pj_custom_function::SeriesAccessor; constexpr size_t kPreviewMaxPoints = 2000; From 31ac0f3df34cd45aba7221bf7d01f1d3b6073958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 03:51:32 +0200 Subject: [PATCH 04/10] fix(toolbox_filter_editor): host SeriesAccessor in builtin_transforms.hpp The dialog's preview path needs a plain timestamp/value view next to the BinaryOp enum that already lives in PJ::sdk inside this plugin header. Restoring it here avoids pulling sol2 through custom_function_engine.hpp (unavailable in the per-plugin-build sandbox), and matches the BinaryOp 'plugin-private type living in PJ::sdk' pattern already established. --- toolbox_filter_editor/builtin_transforms.hpp | 14 ++++++++++++++ toolbox_filter_editor/filter_editor_plugin.cpp | 3 +-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/toolbox_filter_editor/builtin_transforms.hpp b/toolbox_filter_editor/builtin_transforms.hpp index df6b7b1..0477293 100644 --- a/toolbox_filter_editor/builtin_transforms.hpp +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -47,6 +47,20 @@ namespace PJ::sdk { +// Read-only timestamp/value view over one input series — used by the dialog's +// preview / clipboard path. Not part of the FilterTransform contract surface +// (the host never sees it). +struct SeriesAccessor { + std::vector timestamps; + std::vector values; + [[nodiscard]] std::size_t size() const { + return timestamps.size(); + } + [[nodiscard]] bool empty() const { + return timestamps.empty(); + } +}; + // --------------------------------------------------------------------------- // Concrete transforms // --------------------------------------------------------------------------- diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index 2cee93c..4e0b37d 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -22,7 +22,6 @@ #include #include "builtin_transforms.hpp" -#include "custom_function_engine.hpp" #include "filter_editor_dialog_ui.hpp" #include "filter_editor_manifest.hpp" @@ -43,8 +42,8 @@ using PJ::sdk::Point2; using PJ::sdk::registerAllTransforms; using PJ::sdk::SamplesCounterTransform; using PJ::sdk::ScaleTransform; +using PJ::sdk::SeriesAccessor; using PJ::sdk::TimeSincePreviousTransform; -using pj_custom_function::SeriesAccessor; constexpr size_t kPreviewMaxPoints = 2000; From 91107439aa5f9d30d9645bef8dd11b1fa338be8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 04:19:50 +0200 Subject: [PATCH 05/10] fix(toolbox_filter_editor): drop GCC-only pragmas for MSVC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSVC treats #pragma GCC as unknown (C4068) and -Werror promotes it to a build error. Each push had no matching pop, so the suppression was both non-portable and globally leaking. The (void)json_str cast already silences unused-param on every compiler — keep that, drop the pragmas. --- toolbox_filter_editor/builtin_transforms.hpp | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/toolbox_filter_editor/builtin_transforms.hpp b/toolbox_filter_editor/builtin_transforms.hpp index 0477293..7f2589a 100644 --- a/toolbox_filter_editor/builtin_transforms.hpp +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -215,8 +215,6 @@ class DerivativeTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -286,8 +284,6 @@ class IntegralTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -359,8 +355,6 @@ class MovingAverageTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -424,8 +418,6 @@ class MovingRMSTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -498,8 +490,6 @@ class MovingVarianceTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -598,8 +588,6 @@ class OutlierRemovalTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -686,8 +674,6 @@ class SamplesCounterTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON @@ -767,8 +753,6 @@ class BinaryFilterTransform : public FilterTransform { return "{}"; #endif } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" void loadParams(const std::string& json_str) override { (void)json_str; // suppress unused-param in non-json build #ifdef PJ_TRANSFORM_HAS_JSON From 6c79945f029247c9366cf9961b858684fad82dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 10:13:29 +0200 Subject: [PATCH 06/10] feat(toolbox_filter_editor): add "Generate time series" button Write the filtered preview as a persistent time series via the toolbox write host. Lets users materialize a transform result into a saved curve without leaving the dialog. --- toolbox_filter_editor/filter_editor_dialog.ui | 1 + .../filter_editor_plugin.cpp | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/toolbox_filter_editor/filter_editor_dialog.ui b/toolbox_filter_editor/filter_editor_dialog.ui index be1e340..db6624b 100644 --- a/toolbox_filter_editor/filter_editor_dialog.ui +++ b/toolbox_filter_editor/filter_editor_dialog.ui @@ -331,6 +331,7 @@ 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 index 4e0b37d..3ab9d85 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -229,6 +229,7 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { 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_); @@ -471,6 +472,14 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { 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_(); @@ -496,6 +505,9 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { 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); } @@ -813,6 +825,7 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { ApplyFn save_fn_; std::function cancel_fn_; ApplyFn reset_fn_; + ApplyFn generate_fn_; std::string pending_close_; }; @@ -840,6 +853,7 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { 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_); } @@ -955,6 +969,52 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { 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(); From 6e9ce8a74b92e07b6ab8edaa4c1e00b70536b634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 10:13:29 +0200 Subject: [PATCH 07/10] docs(toolbox_filter_editor): clarify OutlierRemoval one-sample delay is intentional Mirrors PJ3 behaviour where the outlier detector buffers the previous sample to compare against the next. The one-sample output lag is by design, not a streaming-mode regression. --- toolbox_filter_editor/builtin_transforms.hpp | 5 +++ .../filter_editor_plugin.cpp | 31 +++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/toolbox_filter_editor/builtin_transforms.hpp b/toolbox_filter_editor/builtin_transforms.hpp index 7f2589a..410e25f 100644 --- a/toolbox_filter_editor/builtin_transforms.hpp +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -554,6 +554,10 @@ class OutlierRemovalTransform : public FilterTransform { } } if (!drop) { + // Intentional PJ3 parity: output the *previous* point (i-1), not the + // current one (i). The detector needs one look-ahead sample to decide + // whether i-1 is an outlier, so the series is delayed by one sample. + // Changing this to output input[i] would break bit-identity with PJ3. out.push_back({input[i - 1].x, input[i - 1].y}); } } @@ -577,6 +581,7 @@ class OutlierRemovalTransform : public FilterTransform { return std::nullopt; } } + // Intentional PJ3 parity: output the previous point (i-1). See applyBatch comment. return Point2{buf_[i - 1].x, buf_[i - 1].y}; } std::string saveParams() const override { diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index 3ab9d85..2c2353a 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -980,9 +980,13 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { std::vector created_names; for (const auto& series_name : dialog_.sourceSeriesList()) { auto source = readSource(series_name); - if (!source) continue; + if (!source) { + continue; + } auto* transform = dialog_.currentTransform(); - if (!transform || std::string(transform->id()) == "none") continue; + if (!transform || std::string(transform->id()) == "none") { + continue; + } std::vector in, output; in.reserve(source->size()); @@ -990,26 +994,35 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { in.push_back({source->timestamps[i], source->values[i]}); } output = transform->applyBatch(in); - if (output.empty()) continue; + 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; + if (!src) { + continue; + } auto topic = host.ensureTopic(*src, out_name); - if (!topic) continue; + 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)); + (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"; + 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 += ", "; + if (i > 0) { + msg += ", "; + } msg += "'" + created_names[i] + "'"; } return msg; From ae8de94e64d3835632d8b209b1d132452a7ccb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 11 Jun 2026 10:26:13 +0200 Subject: [PATCH 08/10] fix(toolbox_filter_editor): use Point2 in generateAndReport path Generate-time-series path still referenced the old Point name; rename matches the SDK contract type used everywhere else in this file. --- toolbox_filter_editor/filter_editor_plugin.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index 2c2353a..fb6b4ca 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -988,7 +988,7 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { continue; } - std::vector in, output; + 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]}); From 12f7c8d6948714b918cd3c1b27a5dc939c9a6356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 12 Jun 2026 12:11:14 +0200 Subject: [PATCH 09/10] refactor(toolbox_filter_editor): pull builtins from SDK; move SeriesAccessor inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 12 strategy classes now live in the SDK header pj_plugins/sdk/builtin_transforms.hpp — this plugin's builtin_transforms.hpp becomes a thin re-export so the dialog (preview/save/load) and the host (live filter / streaming) build against the same source. SeriesAccessor is plugin-internal (only the dialog's preview uses it) so it moves out of the header and into the .cpp next to its only consumer. --- toolbox_filter_editor/builtin_transforms.hpp | 843 +----------------- .../filter_editor_plugin.cpp | 15 +- 2 files changed, 19 insertions(+), 839 deletions(-) diff --git a/toolbox_filter_editor/builtin_transforms.hpp b/toolbox_filter_editor/builtin_transforms.hpp index 410e25f..a5722be 100644 --- a/toolbox_filter_editor/builtin_transforms.hpp +++ b/toolbox_filter_editor/builtin_transforms.hpp @@ -2,842 +2,9 @@ // Copyright 2026 Davide Faconti // SPDX-License-Identifier: MPL-2.0 -// Built-in Filter Transform catalogue — concrete classes that ship with the -// Filter Editor toolbox. Each class implements the abstract -// PJ::sdk::FilterTransform contract defined in the SDK -// (plotjuggler_sdk/pj_plugins/filter_protocol/include/pj_plugins/sdk/filter_transform.hpp). -// -// The classes mirror PJ3's TransformFunction_SISO catalogue: each one owns its -// own parameters, implements calculateNextPoint() for SISO streaming, persists -// itself via saveParams() / loadParams(), and clones via clone(). Pure C++20 — -// no Qt, no Lua, no datastore — fully unit-testable in isolation. -// -// Registration: the plugin's init code calls registerAllTransforms( -// PJ::sdk::FilterTransformFactory::instance()) at load, so the host (PJ4 app) -// can later look the transforms up by id and obtain instances without knowing -// the catalogue at compile time. A future plugin can add its own classes the -// same way without touching the SDK. +// 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 -#include -#include -#include -#include -#include -#include -#include - -// nlohmann/json is required for per-transform parameter persistence. The -// builtin_transforms_test below links against gtest only; guard the JSON -// include so the classes can be exercised through the computation API without -// the persistence path. -#ifdef NLOHMANN_JSON_HPP -#define PJ_TRANSFORM_HAS_JSON 1 -#else -#ifdef __has_include -#if __has_include() -#include -#define PJ_TRANSFORM_HAS_JSON 1 -#endif -#endif -#endif - -#include "pj_plugins/sdk/filter_transform.hpp" -#include "pj_plugins/sdk/filter_transform_factory.hpp" - -namespace PJ::sdk { - -// Read-only timestamp/value view over one input series — used by the dialog's -// preview / clipboard path. Not part of the FilterTransform contract surface -// (the host never sees it). -struct SeriesAccessor { - std::vector timestamps; - std::vector values; - [[nodiscard]] std::size_t size() const { - return timestamps.size(); - } - [[nodiscard]] bool empty() const { - return timestamps.empty(); - } -}; - -// --------------------------------------------------------------------------- -// Concrete transforms -// --------------------------------------------------------------------------- - -// --- None (passthrough) --- - -class NoneTransform : public FilterTransform { - public: - const char* id() const override { - return "none"; - } - const char* label() const override { - return "-- No Transform --"; - } - const char* bracketLabel() const override { - return "copy"; - } - bool isStreamSafe() const override { - return true; - } - void reset() override {} - std::optional calculateNextPoint(const Point2& in) override { - return in; - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } -}; - -// --- Absolute --- - -class AbsoluteTransform : public FilterTransform { - public: - const char* id() const override { - return "absolute"; - } - const char* label() const override { - return "Absolute"; - } - const char* bracketLabel() const override { - return "Absolute"; - } - bool isStreamSafe() const override { - return true; - } - void reset() override {} - std::optional calculateNextPoint(const Point2& in) override { - return Point2{in.x, std::abs(in.y)}; - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } -}; - -// --- Scale / Offset --- - -class ScaleTransform : public FilterTransform { - public: - double value_scale = 1.0; - double value_offset = 0.0; - double time_offset = 0.0; - - const char* id() const override { - return "scale"; - } - const char* label() const override { - return "Scale/Offset"; - } - const char* bracketLabel() const override { - return "Scale"; - } - bool isStreamSafe() const override { - return true; - } - void reset() override {} - std::optional calculateNextPoint(const Point2& in) override { - return Point2{in.x + time_offset, value_scale * in.y + value_offset}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["value_scale"] = value_scale; - j["value_offset"] = value_offset; - j["time_offset"] = time_offset; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - value_scale = j.value("value_scale", 1.0); - value_offset = j.value("value_offset", 0.0); - time_offset = j.value("time_offset", 0.0); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } -}; - -// --- Derivative --- - -class DerivativeTransform : public FilterTransform { - public: - bool use_custom_dt = false; - double custom_dt = 1.0; - - const char* id() const override { - return "derivative"; - } - const char* label() const override { - return "Derivative"; - } - const char* bracketLabel() const override { - return "Derivative"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - prev_ = std::nullopt; - } - - std::optional calculateNextPoint(const Point2& in) override { - if (!prev_.has_value()) { - prev_ = in; - return std::nullopt; - } - const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); - if (dt <= 0.0) { - prev_ = in; - return std::nullopt; - } - const Point2 out{prev_->x, (in.y - prev_->y) / dt}; - prev_ = in; - return out; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["use_custom_dt"] = use_custom_dt; - j["custom_dt"] = custom_dt; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - use_custom_dt = j.value("use_custom_dt", false); - custom_dt = j.value("custom_dt", 1.0); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::optional prev_; -}; - -// --- Integral (trapezoid) --- - -class IntegralTransform : public FilterTransform { - public: - bool use_custom_dt = false; - double custom_dt = 1.0; - - const char* id() const override { - return "integral"; - } - const char* label() const override { - return "Integral"; - } - const char* bracketLabel() const override { - return "Integral"; - } - // Unbounded running accumulator — correct only when all prior points - // have been processed in order from the start; not incrementally safe. - bool isStreamSafe() const override { - return false; - } - - void reset() override { - acc_ = 0.0; - prev_ = std::nullopt; - } - - std::optional calculateNextPoint(const Point2& in) override { - if (!prev_.has_value()) { - prev_ = in; - return std::nullopt; - } - const double dt = use_custom_dt ? custom_dt : (in.x - prev_->x); - if (dt > 0.0) { - acc_ += (in.y + prev_->y) * dt / 2.0; - } - const Point2 out{in.x, acc_}; - prev_ = in; - return out; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["use_custom_dt"] = use_custom_dt; - j["custom_dt"] = custom_dt; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - use_custom_dt = j.value("use_custom_dt", false); - custom_dt = j.value("custom_dt", 1.0); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - double acc_ = 0.0; - std::optional prev_; -}; - -// --- Moving Average --- - -class MovingAverageTransform : public FilterTransform { - public: - int window = 10; - bool compensate_time_offset = false; - - const char* id() const override { - return "moving_average"; - } - const char* label() const override { - return "Moving Average"; - } - const char* bracketLabel() const override { - return "Moving Average"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - buf_.clear(); - } - - std::optional calculateNextPoint(const Point2& in) override { - buf_.push_back(in); - const size_t w = static_cast(std::max(1, window)); - while (buf_.size() > w) { - buf_.erase(buf_.begin()); - } - // Pad underfull window with current point (PJ3 semantics) - double total = in.y * static_cast(w - buf_.size()); - for (const auto& p : buf_) { - total += p.y; - } - double t = in.x; - if (compensate_time_offset && buf_.size() > 1) { - t = (buf_.front().x + buf_.back().x) / 2.0; - } - return Point2{t, total / static_cast(w)}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["window"] = window; - j["compensate_time_offset"] = compensate_time_offset; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - window = j.value("window", 10); - compensate_time_offset = j.value("compensate_time_offset", false); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::vector buf_; -}; - -// --- Moving RMS --- - -class MovingRMSTransform : public FilterTransform { - public: - int window = 10; - - const char* id() const override { - return "moving_rms"; - } - const char* label() const override { - return "Moving Root Mean Squared"; - } - const char* bracketLabel() const override { - return "Moving Root Mean Squared"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - buf_.clear(); - } - - std::optional calculateNextPoint(const Point2& in) override { - buf_.push_back(in); - const size_t w = static_cast(std::max(1, window)); - while (buf_.size() > w) { - buf_.erase(buf_.begin()); - } - double total_sqr = in.y * in.y * static_cast(w - buf_.size()); - for (const auto& p : buf_) { - total_sqr += p.y * p.y; - } - return Point2{in.x, std::sqrt(total_sqr / static_cast(w))}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["window"] = window; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - window = j.value("window", 10); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::vector buf_; -}; - -// --- Moving Variance / Stdev --- - -class MovingVarianceTransform : public FilterTransform { - public: - int window = 10; - bool std_dev = false; // true → output sqrt(variance) - - const char* id() const override { - return "moving_variance"; - } - const char* label() const override { - return "Moving Variance / Stdev"; - } - const char* bracketLabel() const override { - return "Moving Variance / Stdev"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - buf_.clear(); - } - - std::optional calculateNextPoint(const Point2& in) override { - buf_.push_back(in); - const size_t w = static_cast(std::max(1, window)); - while (buf_.size() > w) { - buf_.erase(buf_.begin()); - } - const double pad = static_cast(w - buf_.size()); - double total = in.y * pad; - for (const auto& p : buf_) { - total += p.y; - } - const double avg = total / static_cast(w); - double total_sqr = (in.y - avg) * (in.y - avg) * pad; - for (const auto& p : buf_) { - const double v = p.y - avg; - total_sqr += v * v; - } - const double var = total_sqr / static_cast(w); - return Point2{in.x, std_dev ? std::sqrt(var) : var}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["window"] = window; - j["std_dev"] = std_dev; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - window = j.value("window", 10); - std_dev = j.value("std_dev", false); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::vector buf_; -}; - -// --- Outlier Removal --- - -class OutlierRemovalTransform : public FilterTransform { - public: - double outlier_factor = 100.0; - - const char* id() const override { - return "outlier_removal"; - } - const char* label() const override { - return "Outlier Removal"; - } - const char* bracketLabel() const override { - return "Outlier Removal"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - buf_.clear(); - } - - // Overrides applyBatch: needs 4-sample look-ahead ring (PJ3 semantics). - std::vector applyBatch(const std::vector& input) override { - const size_t n = input.size(); - std::vector out; - out.reserve(n); - for (size_t i = 0; i < n; ++i) { - if (i < 3) { - out.push_back(input[i]); - continue; - } - const double d1 = input[i - 2].y - input[i - 1].y; - const double d2 = input[i - 1].y - input[i].y; - bool drop = false; - if (d1 * d2 < 0) { - const double d0 = input[i - 3].y - input[i - 2].y; - const double jump = std::max(std::abs(d1), std::abs(d2)); - const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); - if (ratio > outlier_factor) { - drop = true; - } - } - if (!drop) { - // Intentional PJ3 parity: output the *previous* point (i-1), not the - // current one (i). The detector needs one look-ahead sample to decide - // whether i-1 is an outlier, so the series is delayed by one sample. - // Changing this to output input[i] would break bit-identity with PJ3. - out.push_back({input[i - 1].x, input[i - 1].y}); - } - } - return out; - } - - // calculateNextPoint not used (applyBatch overridden), but required by interface. - std::optional calculateNextPoint(const Point2& in) override { - buf_.push_back(in); - if (buf_.size() < 4) { - return in; - } - const size_t i = buf_.size() - 1; - const double d1 = buf_[i - 2].y - buf_[i - 1].y; - const double d2 = buf_[i - 1].y - buf_[i].y; - if (d1 * d2 < 0) { - const double d0 = buf_[i - 3].y - buf_[i - 2].y; - const double jump = std::max(std::abs(d1), std::abs(d2)); - const double ratio = (d0 == 0.0) ? std::numeric_limits::infinity() : jump / std::abs(d0); - if (ratio > outlier_factor) { - return std::nullopt; - } - } - // Intentional PJ3 parity: output the previous point (i-1). See applyBatch comment. - return Point2{buf_[i - 1].x, buf_[i - 1].y}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["outlier_factor"] = outlier_factor; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - outlier_factor = j.value("outlier_factor", 100.0); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::vector buf_; -}; - -// --- Samples Counter --- - -class SamplesCounterTransform : public FilterTransform { - public: - int samples_ms = 1000; - - const char* id() const override { - return "samples_counter"; - } - const char* label() const override { - return "Samples Counter"; - } - const char* bracketLabel() const override { - return "Samples Counter"; - } - // Time-windowed look-back: needs all prior points in the window. - bool isStreamSafe() const override { - return false; - } - - void reset() override { - all_.clear(); - } - - // Overrides applyBatch for correct time-window semantics. - std::vector applyBatch(const std::vector& input) override { - const size_t n = input.size(); - const double delta = 0.001 * static_cast(samples_ms); - std::vector out; - out.reserve(n); - for (size_t i = 0; i < n; ++i) { - const double min_t = input[i].x - delta; - size_t lo = 0, hi = i + 1; - while (lo < hi) { - const size_t mid = lo + (hi - lo) / 2; - if (input[mid].x < min_t) { - lo = mid + 1; - } else { - hi = mid; - } - } - out.push_back({input[i].x, static_cast(i - lo)}); - } - return out; - } - - std::optional calculateNextPoint(const Point2& in) override { - all_.push_back(in); - const double delta = 0.001 * static_cast(samples_ms); - const double min_t = in.x - delta; - size_t count = 0; - for (size_t i = all_.size(); i-- > 0;) { - if (all_[i].x < min_t) { - break; - } - ++count; - } - return Point2{in.x, static_cast(count - 1)}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["samples_ms"] = samples_ms; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - samples_ms = j.value("samples_ms", 1000); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::vector all_; -}; - -// --- Binary Filter --- - -enum class BinaryOp { kEqual, kLess, kLessEq, kGreater, kGreaterEq, kRange }; - -class BinaryFilterTransform : public FilterTransform { - public: - BinaryOp op = BinaryOp::kGreater; - double a = 0.0; - double b = 0.0; - - const char* id() const override { - return "binary_filter"; - } - const char* label() const override { - return "Binary Filter"; - } - const char* bracketLabel() const override { - return "Binary Filter"; - } - bool isStreamSafe() const override { - return true; - } - void reset() override {} - - std::optional calculateNextPoint(const Point2& in) override { - double r = 0.0; - switch (op) { - case BinaryOp::kEqual: - r = (std::abs(in.y - a) <= 1e-9 * std::max(1.0, std::abs(a))) ? 1.0 : 0.0; - break; - case BinaryOp::kLess: - r = (in.y < a) ? 1.0 : 0.0; - break; - case BinaryOp::kLessEq: - r = (in.y <= a) ? 1.0 : 0.0; - break; - case BinaryOp::kGreater: - r = (in.y > a) ? 1.0 : 0.0; - break; - case BinaryOp::kGreaterEq: - r = (in.y >= a) ? 1.0 : 0.0; - break; - case BinaryOp::kRange: { - const double lo = std::min(a, b), hi = std::max(a, b); - r = (in.y >= lo && in.y <= hi) ? 1.0 : 0.0; - break; - } - } - return Point2{in.x, r}; - } - std::string saveParams() const override { -#ifdef PJ_TRANSFORM_HAS_JSON - nlohmann::json j; - j["binary_op"] = static_cast(op); - j["binary_a"] = a; - j["binary_b"] = b; - return j.dump(); -#else - return "{}"; -#endif - } - void loadParams(const std::string& json_str) override { - (void)json_str; // suppress unused-param in non-json build -#ifdef PJ_TRANSFORM_HAS_JSON - auto j = nlohmann::json::parse(json_str, nullptr, false); - if (j.is_discarded()) { - return; - } - op = static_cast(j.value("binary_op", static_cast(BinaryOp::kGreater))); - a = j.value("binary_a", 0.0); - b = j.value("binary_b", 0.0); -#endif - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } -}; - -// --- Time Since Previous Point2 --- - -class TimeSincePreviousTransform : public FilterTransform { - public: - const char* id() const override { - return "time_since_previous"; - } - const char* label() const override { - return "Time Since Previous Point2"; - } - const char* bracketLabel() const override { - return "Time Since Previous Point2"; - } - bool isStreamSafe() const override { - return true; - } - - void reset() override { - prev_t_ = std::nullopt; - } - - std::optional calculateNextPoint(const Point2& in) override { - if (!prev_t_.has_value()) { - prev_t_ = in.x; - return std::nullopt; - } - const Point2 out{in.x, in.x - *prev_t_}; - prev_t_ = in.x; - return out; - } - std::unique_ptr clone() const override { - return std::make_unique(*this); - } - - private: - std::optional prev_t_; -}; - -// --------------------------------------------------------------------------- -// Registration — all transforms in display order -// --------------------------------------------------------------------------- - -inline void registerAllTransforms() { - auto& f = FilterTransformFactory::instance(); - // Guard: only register once. - static bool registered = false; - if (registered) { - return; - } - registered = true; - - f.registerTransform(NoneTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(AbsoluteTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(ScaleTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(DerivativeTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(IntegralTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingAverageTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingRMSTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(MovingVarianceTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(OutlierRemovalTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(SamplesCounterTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(BinaryFilterTransform{}.id(), [] { return std::make_unique(); }); - f.registerTransform(TimeSincePreviousTransform{}.id(), [] { return std::make_unique(); }); -} - -} // namespace PJ::sdk +#include "pj_plugins/sdk/builtin_transforms.hpp" diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index fb6b4ca..53ca970 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -42,9 +42,22 @@ using PJ::sdk::Point2; using PJ::sdk::registerAllTransforms; using PJ::sdk::SamplesCounterTransform; using PJ::sdk::ScaleTransform; -using PJ::sdk::SeriesAccessor; 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". From f58993789c1df5ede5b92d1237a1f41e94b3e49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 12 Jun 2026 12:33:31 +0200 Subject: [PATCH 10/10] refactor(toolbox_filter_editor): register builtins via pj.filter_registry.v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin no longer owns its own FilterTransformFactory singleton. At bind() it grabs the host-provided FilterRegistryView and registers the 12 builtin classes into the host's single factory; the dialog resolves transforms from the same view so preview, save/load, and the host's streaming read path all hit one source of truth. - bind() override: walk the catalogue of 12 strategies (NoneTransform through TimeSincePreviousTransform), registering each via view.registerTransform("id", {}). - FilterEditorDialog: hold a FilterRegistryView member, set by the toolbox after bind. All transform_ creations now go through registry_view_.create(); transform_ moves from unique_ptr to shared_ptr to match the view's return type. - getDialog(): drop registerAllTransforms() — the registration now happens once at bind, not on every panel open. - Drop the local FilterTransformFactory / registerAllTransforms using-decls — both are gone from the SDK header. --- .../filter_editor_plugin.cpp | 73 +++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/toolbox_filter_editor/filter_editor_plugin.cpp b/toolbox_filter_editor/filter_editor_plugin.cpp index 53ca970..5b61aac 100644 --- a/toolbox_filter_editor/filter_editor_plugin.cpp +++ b/toolbox_filter_editor/filter_editor_plugin.cpp @@ -24,14 +24,16 @@ #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::FilterTransformFactory; using PJ::sdk::IntegralTransform; using PJ::sdk::MovingAverageTransform; using PJ::sdk::MovingRMSTransform; @@ -39,7 +41,6 @@ using PJ::sdk::MovingVarianceTransform; using PJ::sdk::NoneTransform; using PJ::sdk::OutlierRemovalTransform; using PJ::sdk::Point2; -using PJ::sdk::registerAllTransforms; using PJ::sdk::SamplesCounterTransform; using PJ::sdk::ScaleTransform; using PJ::sdk::TimeSincePreviousTransform; @@ -128,6 +129,13 @@ 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; } @@ -154,12 +162,12 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { // Transform list — built from the factory registry (no hardcoded enum). { - const auto ids = FilterTransformFactory::instance().registeredIds(); + 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 = FilterTransformFactory::instance().create(tid); + auto t = registry_view_.create(tid); if (t) { labels.push_back(t->label()); } @@ -264,9 +272,9 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { return true; } if (name == "transform_list" && !items.empty()) { - const auto ids = FilterTransformFactory::instance().registeredIds(); + const auto ids = registry_view_.registeredIds(); for (const auto& tid : ids) { - auto t = FilterTransformFactory::instance().create(tid); + auto t = registry_view_.create(tid); if (t && std::string(t->label()) == items.front()) { transform_ = std::move(t); break; @@ -480,7 +488,7 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { return true; } if (name == "reset_btn") { - transform_ = std::make_unique(); + transform_ = std::make_shared(); preview_dirty_ = true; status_msg_ = reset_fn_ ? reset_fn_() : std::string("Reset"); return true; @@ -526,7 +534,7 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { } void resetToNoTransform() { - transform_ = std::make_unique(); + transform_ = std::make_shared(); alias_.clear(); alias_edited_by_user_ = false; preview_dirty_ = true; @@ -649,9 +657,9 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { } { const std::string tid = j.value("transform", std::string{"none"}); - transform_ = FilterTransformFactory::instance().create(tid); + transform_ = registry_view_.create(tid); if (!transform_) { - transform_ = std::make_unique(); + transform_ = std::make_shared(); } if (j.contains("transform_params")) { transform_->loadParams(j["transform_params"].dump()); @@ -824,8 +832,13 @@ class FilterEditorDialog : public PJ::DialogPluginTyped { 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::unique_ptr transform_ = std::make_unique(); + 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_; @@ -852,9 +865,42 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { 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 { - // Ensure all transforms are registered before the dialog is shown. - registerAllTransforms(); if (toolboxHostBound()) { std::vector names; std::unordered_map data; @@ -1146,6 +1192,7 @@ class FilterEditorToolbox : public PJ::ToolboxPluginBase { } FilterEditorDialog dialog_; + FilterRegistryView registry_view_; bool created_ = false; std::string config_at_open_; std::string last_applied_config_;