Skip to content

Commit 3e06189

Browse files
feat(toolbox_filter_editor): Filter Editor toolbox plugin
Per-curve filter editor with live preview, Save (volatile read-path transform), Generate (writes a persistent derived time series), and a streaming-aware Generate continuation that keeps the derived series appending sample-by-sample as the source advances under a sliding retention window. Highlights: - Pulls the 12 built-in FilterTransform classes from the SDK via the pj.filter_registry.v1 service; the toolbox registers them through the ServiceRegistryBuilder so the host's read path uses the SAME factory instance (one source of truth for builtins). - Generate streaming continuation: per-source stateful FilterTransform clone cached in GenerateState, fed only the NEW tail each tick via appendTail — keeps MovingAverage's internal buffer warm and avoids edge-effect artifacts when the source slides through retention. - Save accepts the "none" transform too: lets the user explicitly commit a reset-to-original via Save (the host's on_data_changed sees inc_fn null for "none" and falls through to clearCurveTransform). Generate keeps the stricter isValid().
1 parent 317ea24 commit 3e06189

11 files changed

Lines changed: 2374 additions & 4 deletions

.gitmodules

Lines changed: 0 additions & 3 deletions
This file was deleted.

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ if(TARGET plotjuggler_sdk::plugin_sdk)
4747
add_subdirectory(toolbox_colormap)
4848
add_subdirectory(toolbox_quaternion)
4949
add_subdirectory(toolbox_reactive_scripts_editor)
50+
add_subdirectory(toolbox_filter_editor)
5051
add_subdirectory(toolbox_mosaico)
5152
return()
5253
endif()
@@ -153,5 +154,6 @@ else()
153154
add_subdirectory(toolbox_fft)
154155
add_subdirectory(toolbox_colormap)
155156
add_subdirectory(toolbox_quaternion)
157+
add_subdirectory(toolbox_filter_editor)
156158
add_subdirectory(toolbox_mosaico)
157159
endif()

extern/plotjuggler_core

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
find_package(nlohmann_json REQUIRED)
2+
3+
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedManifest.cmake)
4+
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedUi.cmake)
5+
6+
add_library(toolbox_filter_editor_plugin SHARED filter_editor_plugin.cpp)
7+
target_compile_features(toolbox_filter_editor_plugin PRIVATE cxx_std_20)
8+
target_compile_options(toolbox_filter_editor_plugin PRIVATE ${PJ_WARNING_FLAGS})
9+
10+
target_link_libraries(toolbox_filter_editor_plugin PRIVATE
11+
plotjuggler_sdk::plugin_sdk
12+
nlohmann_json::nlohmann_json
13+
${CMAKE_DL_LIBS}
14+
)
15+
16+
pj_embed_manifest(toolbox_filter_editor_plugin
17+
HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_manifest.hpp
18+
VAR_NAME kFilterEditorManifest
19+
)
20+
21+
pj_embed_ui(toolbox_filter_editor_plugin
22+
UI_FILE ${CMAKE_CURRENT_SOURCE_DIR}/filter_editor_dialog.ui
23+
HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_dialog_ui.hpp
24+
VAR_NAME kFilterEditorDialogUi
25+
)
26+
27+
# v4 plugin discovery: emit a sidecar .pjmanifest.json the host can scan
28+
# pre-dlopen. Content derived from manifest.json + {abi_major, family}.
29+
pj_emit_plugin_manifest(toolbox_filter_editor_plugin
30+
FAMILY toolbox
31+
MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json
32+
)
33+
34+
# Built-in predefined transforms catalogue lives in this plugin (it provides
35+
# them); the SDK contract they implement is in plotjuggler_sdk::plugin_sdk.
36+
# Tests guard the per-transform PJ3-mirror math + the SDK contract (the
37+
# default appendTail loops calculateNextPoint, save/load round-trips).
38+
if(TARGET GTest::gtest_main)
39+
add_executable(builtin_transforms_test builtin_transforms_test.cpp)
40+
target_compile_features(builtin_transforms_test PRIVATE cxx_std_20)
41+
target_compile_options(builtin_transforms_test PRIVATE ${PJ_WARNING_FLAGS})
42+
target_link_libraries(builtin_transforms_test PRIVATE
43+
plotjuggler_sdk::plugin_sdk
44+
nlohmann_json::nlohmann_json
45+
GTest::gtest_main
46+
)
47+
add_test(NAME builtin_transforms_test COMMAND builtin_transforms_test)
48+
endif()
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright 2026 Davide Faconti
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
#include "pj_plugins/sdk/builtin_transforms.hpp"
5+
6+
#include <gtest/gtest.h>
7+
8+
#include <cmath>
9+
#include <cstdint>
10+
#include <limits>
11+
12+
#include "pj_plugins/sdk/filter_transform_factory.hpp"
13+
14+
namespace {
15+
16+
using namespace PJ::sdk; // NOLINT
17+
18+
// Helper: apply a transform to (xs, ys) pairs and return output points.
19+
std::vector<Point2> apply(FilterTransform& t, std::vector<double> xs, std::vector<double> ys) {
20+
std::vector<Point2> in;
21+
in.reserve(xs.size());
22+
for (size_t i = 0; i < xs.size(); ++i) {
23+
in.push_back({xs[i], ys[i]});
24+
}
25+
return t.applyBatch(in);
26+
}
27+
28+
TEST(Transforms, Absolute) {
29+
AbsoluteTransform t;
30+
auto out = apply(t, {0, 1, 2}, {-3, 4, -5});
31+
ASSERT_EQ(out.size(), 3u);
32+
EXPECT_DOUBLE_EQ(out[0].y, 3.0);
33+
EXPECT_DOUBLE_EQ(out[2].y, 5.0);
34+
}
35+
36+
TEST(Transforms, ScaleAndOffset) {
37+
ScaleTransform t;
38+
t.value_scale = 2.0;
39+
t.value_offset = 1.0;
40+
t.time_offset = 10.0;
41+
auto out = apply(t, {0, 1}, {3, 4});
42+
EXPECT_DOUBLE_EQ(out[0].x, 10.0);
43+
EXPECT_DOUBLE_EQ(out[0].y, 7.0);
44+
EXPECT_DOUBLE_EQ(out[1].y, 9.0);
45+
}
46+
47+
TEST(Transforms, DerivativeActualDt) {
48+
DerivativeTransform t;
49+
auto out = apply(t, {0, 1, 2}, {0, 10, 30});
50+
ASSERT_EQ(out.size(), 2u);
51+
EXPECT_DOUBLE_EQ(out[0].y, 10.0);
52+
EXPECT_DOUBLE_EQ(out[1].y, 20.0);
53+
}
54+
55+
TEST(Transforms, IntegralTrapezoid) {
56+
IntegralTransform t;
57+
auto out = apply(t, {0, 1, 2}, {0, 2, 4});
58+
ASSERT_EQ(out.size(), 2u);
59+
EXPECT_DOUBLE_EQ(out[0].y, 1.0);
60+
EXPECT_DOUBLE_EQ(out[1].y, 4.0);
61+
}
62+
63+
TEST(Transforms, MovingAverageWindow) {
64+
MovingAverageTransform t;
65+
t.window = 2;
66+
auto out = apply(t, {0, 1, 2, 3}, {2, 4, 6, 8});
67+
ASSERT_EQ(out.size(), 4u);
68+
EXPECT_DOUBLE_EQ(out[0].y, 2.0); // padded
69+
EXPECT_DOUBLE_EQ(out[1].y, 3.0); // (2+4)/2
70+
EXPECT_DOUBLE_EQ(out[3].y, 7.0); // (6+8)/2
71+
}
72+
73+
TEST(Transforms, MovingRMS) {
74+
MovingRMSTransform t;
75+
t.window = 2;
76+
auto out = apply(t, {0, 1}, {3, 4});
77+
EXPECT_DOUBLE_EQ(out[0].y, 3.0);
78+
EXPECT_DOUBLE_EQ(out[1].y, std::sqrt((9.0 + 16.0) / 2.0));
79+
}
80+
81+
TEST(Transforms, BinaryFilterGreater) {
82+
BinaryFilterTransform t;
83+
t.op = BinaryOp::kGreater;
84+
t.a = 5.0;
85+
auto out = apply(t, {0, 1, 2}, {3, 5, 7});
86+
EXPECT_DOUBLE_EQ(out[0].y, 0.0);
87+
EXPECT_DOUBLE_EQ(out[1].y, 0.0);
88+
EXPECT_DOUBLE_EQ(out[2].y, 1.0);
89+
}
90+
91+
TEST(Transforms, BinaryFilterRange) {
92+
BinaryFilterTransform t;
93+
t.op = BinaryOp::kRange;
94+
t.a = 2.0;
95+
t.b = 6.0;
96+
auto out = apply(t, {0, 1, 2}, {1, 4, 7});
97+
EXPECT_DOUBLE_EQ(out[0].y, 0.0);
98+
EXPECT_DOUBLE_EQ(out[1].y, 1.0);
99+
EXPECT_DOUBLE_EQ(out[2].y, 0.0);
100+
}
101+
102+
TEST(Transforms, TimeSincePrevious) {
103+
TimeSincePreviousTransform t;
104+
auto out = apply(t, {0, 2, 5}, {9, 9, 9});
105+
ASSERT_EQ(out.size(), 2u);
106+
EXPECT_DOUBLE_EQ(out[0].y, 2.0);
107+
EXPECT_DOUBLE_EQ(out[1].y, 3.0);
108+
}
109+
110+
TEST(Transforms, SamplesCounter) {
111+
SamplesCounterTransform t;
112+
t.samples_ms = 2000;
113+
auto out = apply(t, {0, 1, 2, 3}, {0, 0, 0, 0});
114+
ASSERT_EQ(out.size(), 4u);
115+
EXPECT_DOUBLE_EQ(out[0].y, 0.0);
116+
EXPECT_DOUBLE_EQ(out[2].y, 2.0);
117+
}
118+
119+
TEST(Transforms, NoneIsPassthrough) {
120+
NoneTransform t;
121+
auto out = apply(t, {0, 1}, {5, 6});
122+
ASSERT_EQ(out.size(), 2u);
123+
EXPECT_DOUBLE_EQ(out[1].y, 6.0);
124+
}
125+
126+
TEST(Transforms, FactoryRegistration) {
127+
// The host owns the factory now (one source of truth); for the test we just
128+
// exercise the same registration shape the plugin's loaderInit uses.
129+
FilterTransformFactory f;
130+
f.registerTransform(
131+
"scale", []() -> FilterTransform* { return new ScaleTransform{}; }, [](FilterTransform* p) noexcept { delete p; },
132+
{});
133+
f.registerTransform(
134+
"moving_average", []() -> FilterTransform* { return new MovingAverageTransform{}; },
135+
[](FilterTransform* p) noexcept { delete p; }, {});
136+
137+
const auto ids = f.registeredIds();
138+
EXPECT_FALSE(ids.empty());
139+
for (const auto& id : ids) {
140+
auto t = f.create(id);
141+
EXPECT_NE(t, nullptr) << "Could not create: " << id;
142+
}
143+
}
144+
145+
TEST(Transforms, StreamSafeFlags) {
146+
// Known stream-safe
147+
EXPECT_TRUE(AbsoluteTransform{}.isStreamSafe());
148+
EXPECT_TRUE(ScaleTransform{}.isStreamSafe());
149+
EXPECT_TRUE(DerivativeTransform{}.isStreamSafe());
150+
EXPECT_TRUE(MovingAverageTransform{}.isStreamSafe());
151+
EXPECT_TRUE(MovingRMSTransform{}.isStreamSafe());
152+
EXPECT_TRUE(MovingVarianceTransform{}.isStreamSafe());
153+
EXPECT_TRUE(OutlierRemovalTransform{}.isStreamSafe());
154+
EXPECT_TRUE(BinaryFilterTransform{}.isStreamSafe());
155+
EXPECT_TRUE(TimeSincePreviousTransform{}.isStreamSafe());
156+
// Known non-safe (need full history)
157+
EXPECT_FALSE(IntegralTransform{}.isStreamSafe());
158+
EXPECT_FALSE(SamplesCounterTransform{}.isStreamSafe());
159+
}
160+
161+
// ---------------------------------------------------------------------------
162+
// Streaming equivalence: calculateNextPoint matches applyBatch
163+
// ---------------------------------------------------------------------------
164+
165+
void expectStreamingMatchesBatch(FilterTransform& t_batch, FilterTransform& t_stream) {
166+
std::vector<Point2> full;
167+
for (int i = 0; i < 30; ++i) {
168+
full.push_back({static_cast<double>(i), std::sin(i * 0.3) * 5.0 + static_cast<double>(i % 5)});
169+
}
170+
// Batch
171+
const auto batch = t_batch.applyBatch(full);
172+
// Streaming point-by-point
173+
t_stream.reset();
174+
std::vector<Point2> streamed;
175+
for (const auto& p : full) {
176+
if (auto r = t_stream.calculateNextPoint(p)) {
177+
streamed.push_back(*r);
178+
}
179+
}
180+
ASSERT_EQ(batch.size(), streamed.size());
181+
for (size_t i = 0; i < batch.size(); ++i) {
182+
EXPECT_NEAR(batch[i].x, streamed[i].x, 1e-9) << "x mismatch at " << i;
183+
EXPECT_NEAR(batch[i].y, streamed[i].y, 1e-9) << "y mismatch at " << i;
184+
}
185+
}
186+
187+
TEST(Transforms, StreamingMatchesBatch) {
188+
{
189+
AbsoluteTransform a, b;
190+
expectStreamingMatchesBatch(a, b);
191+
}
192+
{
193+
DerivativeTransform a, b;
194+
expectStreamingMatchesBatch(a, b);
195+
}
196+
{
197+
IntegralTransform a, b;
198+
expectStreamingMatchesBatch(a, b);
199+
}
200+
{
201+
MovingAverageTransform a, b;
202+
a.window = b.window = 5;
203+
expectStreamingMatchesBatch(a, b);
204+
}
205+
{
206+
MovingRMSTransform a, b;
207+
a.window = b.window = 5;
208+
expectStreamingMatchesBatch(a, b);
209+
}
210+
{
211+
TimeSincePreviousTransform a, b;
212+
expectStreamingMatchesBatch(a, b);
213+
}
214+
{
215+
BinaryFilterTransform a, b;
216+
a.op = b.op = BinaryOp::kGreater;
217+
a.a = b.a = 2.0;
218+
expectStreamingMatchesBatch(a, b);
219+
}
220+
}
221+
222+
} // namespace

toolbox_filter_editor/conanfile.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
from conan import ConanFile
3+
4+
5+
_SDK_VERSION = (
6+
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SDK_VERSION"))
7+
.read()
8+
.strip()
9+
)
10+
11+
12+
class ToolboxFilterEditorConan(ConanFile):
13+
name = "toolbox_filter_editor"
14+
version = "0"
15+
settings = "os", "compiler", "build_type", "arch"
16+
generators = "CMakeDeps", "CMakeToolchain"
17+
requires = (
18+
f"plotjuggler_sdk/{_SDK_VERSION}",
19+
"gtest/1.17.0",
20+
"nlohmann_json/3.12.0",
21+
)
22+
default_options = {
23+
"*:shared": False,
24+
}

0 commit comments

Comments
 (0)