Round 4 of Task 4 review. Four defects in FrameDecoder's rule 3: - The undeclared-rate (hrt) path positioned each burst at an ABSOLUTE hrt/ticksPerSecond(). hrt counts from the producer's boot, so it is ~1e11 ticks by the time a scope attaches, and the rate is refitted every packet with a few parts in 1e4 of wobble. The product is tens of milliseconds of jitter in BOTH directions -- not merely imprecise, non-monotonic. Integrate short tick deltas into accProdSec instead and let ClockOffset latch the epoch that leaves behind. - The lead bleed used a fixed 0.9 factor, which converges only while the declared rate is within ~10%. Squeeze proportionally to the excess instead (floored at kMinBleedFactor), settling it in a single burst. - A single-sample flush fell through to the plain-scalar rule, dating it from arrival and leaving lastCounter stale so the next real burst reinstated a hole that never existed. Accumulate mode flushes on a timer, so a short cycle legitimately yields one sample; keep it on the chain. - kMaxCounterGap was inert: an absurd gap yields an absurd prediction that the arrival backstop already rejects, and no input can distinguish the two rules. Removed rather than left implying a behaviour it did not have. FrameDecoder.h now states the deliberate divergence from StreamHub -- which converts hrt with the LOCAL MARTe timer frequency, valid only because it runs on the producer's host -- and why a remote scope's drift is irreducible. Three new tests, each sabotage-proven non-vacuous: producer restart, short flushes staying on the chain, and a 20000-packet undeclared run after a day of producer uptime that asserts SPACING as well as ordering (the monotonic guard alone restores order while leaving positions wrong). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 KiB
UDPScope Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build Client/udpscope, an ImGui oscilloscope that attaches directly to one UDPStreamer through the standalone C client, with a splittable multi-pane view and a local trigger.
Architecture: Two threads. A receiver thread runs udps_client_poll() forever, reconstructs per-sample timestamps, appends to per-signal ring buffers and runs the trigger edge detector. The GUI thread runs SDL2 + ImGui + ImPlot, reads windows out of the rings each frame, decimates them with a min/max envelope and draws. SignalStore is the single shared object, guarded by one mutex.
Tech Stack: C++17, SDL2, OpenGL 3.3, Dear ImGui v1.91.8, ImPlot v0.17, GoogleTest, CMake ≥ 3.16. The transport is Common/Client/c/udps_client.c (C99, compiled into the build).
Spec: docs/superpowers/specs/2026-08-27-udpscope-direct-udps-imgui-scope-design.md
Global Constraints
- Language: C++17 for the app, C99 for the vendored
udps_client.c. - Namespace: everything in
namespace udpscope. - Warnings: app compiled
-Wall -Wextra -Wno-unused-parameter; vendored ImGui/ImPlot compiled-w. - Nothing under
Client/streamhub/may be modified.SignalBuffer.handresources/are consumed read-only. PaneTree,TimeBase,FrameDecoder,Trigger,Measure,Decimate,Settings,Exportmust not include any ImGui, SDL or UDPS header. They are unit-tested without a window or a socket.SignalBuffer.h's comment claims it is thread-safe. It is not — it has no locks. All locking lives inSignalStore.- Ring margin constant is
kRingMargin = 4.0and must carry the comment explaining why (a capture harvested after its last sample needs the ring to reach further back than the window itself). - Decimation is min/max envelope, never LTTB.
- Signals are identified by name everywhere in the UI and in settings, never by index.
- This is not a MARTe2 component: STL and C++17 are fine. The "no STL" rule applies only to
Source/Components/. - EUPL v1.1 headers are not required here (matching
Client/streamhub, which has none).
File Structure
| File | Responsibility |
|---|---|
Client/udpscope/CMakeLists.txt |
Build: SDL2, OpenGL, FetchContent ImGui/ImPlot/GoogleTest, udpsclient C target |
Types.h |
Series, Color, Rect, SignalMeta, FrameView — shared plain data, no logic |
Decimate.{h,cpp} |
Min/max envelope decimation |
PaneTree.{h,cpp} |
BSP split tree: split, close, layout, hit-test |
TimeBase.{h,cpp} |
Producer-clock → wall-clock offset; hrt tick-rate fit |
FrameDecoder.{h,cpp} |
Per-element timestamp reconstruction (spec §5) |
Trigger.{h,cpp} |
Edge detector and capture FSM |
SignalStore.{h,cpp} |
Rings, ring sizing, signal table lifecycle, the one mutex |
Receiver.{h,cpp} |
The thread, the udps_client_t, the three C callbacks |
Measure.{h,cpp} |
Window statistics and cursor readouts |
Axes.{h,cpp} |
Shared X-axis controller; per-trace division scaling |
CaptureLatch.{h,cpp} |
Which capture the panes draw; trigger status badge text |
PlotData.{h,cpp} |
Fetching a trace out of a ring or a capture, ready to draw |
Cli.{h,cpp} |
Command-line parsing, usage text, default config path |
Settings.{h,cpp} |
Session file writer and recursive-descent parser |
Export.{h,cpp} |
Long-format CSV writer |
App.{h,cpp} |
Owns everything; per-frame update() |
main.cpp |
SDL/ImGui/ImPlot bootstrap, style, fonts, event loop |
SignalList.cpp |
Left panel, drag source, profile toggle |
PaneView.cpp |
ImPlot rendering of one pane, split handles |
TriggerBar.cpp |
Trigger controls and status badge |
tests/*.cpp |
GoogleTest suites for the framework-free modules |
Task 1: Build scaffold and min/max decimation
Establishes the CMake build (including the test binary) and lands the first framework-free module through it, so the scaffold is proven by something real rather than by an empty target.
Files:
- Create:
Client/udpscope/CMakeLists.txt - Create:
Client/udpscope/Types.h - Create:
Client/udpscope/Decimate.h - Create:
Client/udpscope/Decimate.cpp - Create:
Client/udpscope/tests/DecimateTest.cpp
Interfaces:
-
Consumes: nothing.
-
Produces:
udpscope::Series { std::vector<double> t, v; }udpscope::Color { float r, g, b, a; }udpscope::Rect { double x, y, w, h; }void udpscope::MinMaxDecimate(const double* t, const double* v, size_t n, size_t maxPoints, Series& out)- CMake targets
udpsclient,imgui_lib,UDPScope,udpscope_tests.
-
Step 1: Write the failing test
Create Client/udpscope/tests/DecimateTest.cpp:
#include "Decimate.h"
#include <gtest/gtest.h>
#include <vector>
using namespace udpscope;
TEST(MinMaxDecimate, PassesShortInputThroughUnchanged) {
const std::vector<double> t{0.0, 1.0, 2.0};
const std::vector<double> v{5.0, 6.0, 7.0};
Series out;
MinMaxDecimate(t.data(), v.data(), t.size(), 100, out);
EXPECT_EQ(out.t, t);
EXPECT_EQ(out.v, v);
}
// The whole reason for preferring min/max over LTTB: a single-sample spike is
// usually the thing the user is looking for, and it must survive decimation.
TEST(MinMaxDecimate, PreservesAnIsolatedSpike) {
std::vector<double> t(1000), v(1000, 0.0);
for (size_t i = 0; i < t.size(); i++) { t[i] = static_cast<double>(i); }
v[437] = 42.0;
Series out;
MinMaxDecimate(t.data(), v.data(), t.size(), 50, out);
ASSERT_FALSE(out.v.empty());
EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 42.0);
}
TEST(MinMaxDecimate, PreservesTheExtremesOfEveryBucket) {
std::vector<double> t(100), v(100);
for (size_t i = 0; i < t.size(); i++) {
t[i] = static_cast<double>(i);
v[i] = (i % 10 == 3) ? -9.0 : ((i % 10 == 7) ? 9.0 : 0.0);
}
Series out;
MinMaxDecimate(t.data(), v.data(), t.size(), 20, out);
EXPECT_EQ(*std::min_element(out.v.begin(), out.v.end()), -9.0);
EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 9.0);
}
// A ring whose timestamps are not monotonic breaks any later binary search by
// time, so the pair emitted per bucket must be ordered by time, not by value.
TEST(MinMaxDecimate, EmitsPointsInTimeOrder) {
// Two buckets of four. In the first the minimum comes before the maximum,
// in the second the order is reversed. An implementation that emitted
// (min, max) by value rather than by time passes on bucket 0 and fails on
// bucket 1, so this data exercises the swap that a monotonically growing
// ramp never triggers.
const double st[8] = {0, 1, 2, 3, 4, 5, 6, 7};
const double sv[8] = {-5, 0, 0, 9, 9, 0, 0, -5};
Series pair;
MinMaxDecimate(st, sv, 8, 4, pair);
ASSERT_EQ(pair.size(), 4u);
const double wantT[4] = {0, 3, 4, 7};
const double wantV[4] = {-5, 9, 9, -5};
for (size_t i = 0; i < 4; i++) {
EXPECT_EQ(pair.t[i], wantT[i]) << "time at " << i;
EXPECT_EQ(pair.v[i], wantV[i]) << "value at " << i;
}
std::vector<double> t(400), v(400);
for (size_t i = 0; i < t.size(); i++) {
t[i] = static_cast<double>(i);
v[i] = (i % 2 == 0) ? -static_cast<double>(i) : static_cast<double>(i);
}
Series out;
MinMaxDecimate(t.data(), v.data(), t.size(), 40, out);
ASSERT_GT(out.t.size(), 1u);
for (size_t i = 1; i < out.t.size(); i++) {
EXPECT_LE(out.t[i - 1], out.t[i]) << "at index " << i;
}
}
TEST(MinMaxDecimate, HandlesEmptyInput) {
Series out;
out.t.push_back(1.0); // must be cleared
MinMaxDecimate(nullptr, nullptr, 0, 10, out);
EXPECT_TRUE(out.t.empty());
EXPECT_TRUE(out.v.empty());
}
- Step 2: Create the shared plain-data header
Create Client/udpscope/Types.h:
/**
* @file Types.h
* @brief Plain data shared across UDPScope modules. No logic, no dependencies.
*/
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace udpscope {
/** A time series as two parallel arrays, which is what ImPlot wants. */
struct Series {
std::vector<double> t;
std::vector<double> v;
void clear() { t.clear(); v.clear(); }
size_t size() const { return t.size(); }
bool empty() const { return t.empty(); }
};
/** RGBA in 0..1. Framework-free so PaneTree needs no ImGui. */
struct Color {
float r = 1.f, g = 1.f, b = 1.f, a = 1.f;
};
/** Screen rectangle in pixels. */
struct Rect {
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
bool contains(double px, double py) const {
return px >= x && px < (x + w) && py >= y && py < (y + h);
}
};
} /* namespace udpscope */
- Step 3: Write the decimation header
Create Client/udpscope/Decimate.h:
/**
* @file Decimate.h
* @brief Min/max envelope decimation for screen rendering.
*/
#pragma once
#include "Types.h"
namespace udpscope {
/**
* @brief Reduce n points to at most maxPoints by emitting each bucket's
* minimum and maximum, in time order.
*
* LTTB is deliberately not used. It selects representative points and will
* silently drop a one-sample glitch; on a scope that glitch is usually the
* thing being looked for. The emitted pair stays in time order rather than
* value order because callers binary-search the result by time.
*
* Input shorter than maxPoints is copied through unchanged.
*/
void MinMaxDecimate(const double* t, const double* v, size_t n,
size_t maxPoints, Series& out);
} /* namespace udpscope */
- Step 4: Write the minimal implementation
Create Client/udpscope/Decimate.cpp:
#include "Decimate.h"
#include <algorithm>
namespace udpscope {
void MinMaxDecimate(const double* t, const double* v, size_t n,
size_t maxPoints, Series& out) {
out.clear();
if (n == 0 || t == nullptr || v == nullptr) {
return;
}
if (n <= maxPoints || maxPoints < 4) {
out.t.assign(t, t + n);
out.v.assign(v, v + n);
return;
}
/* Two points per bucket, so the bucket count is half the budget. */
const size_t buckets = maxPoints / 2;
out.t.reserve(buckets * 2);
out.v.reserve(buckets * 2);
for (size_t b = 0; b < buckets; b++) {
const size_t begin = (n * b) / buckets;
size_t end = (n * (b + 1)) / buckets;
if (end <= begin) { end = begin + 1; }
if (end > n) { end = n; }
size_t lo = begin, hi = begin;
for (size_t i = begin + 1; i < end; i++) {
if (v[i] < v[lo]) { lo = i; }
if (v[i] > v[hi]) { hi = i; }
}
const size_t first = std::min(lo, hi);
const size_t second = std::max(lo, hi);
out.t.push_back(t[first]);
out.v.push_back(v[first]);
if (second != first) {
out.t.push_back(t[second]);
out.v.push_back(v[second]);
}
}
}
} /* namespace udpscope */
- Step 5: Write the CMake build
Create Client/udpscope/CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(UDPScope CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 99)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
option(UDPSCOPE_BUILD_TESTS "Build the unit tests" ON)
set(STREAMHUB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../streamhub)
set(CCLIENT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../Common/Client/c)
# ── The standalone C UDPS client, compiled in directly ────────────────────────
# Building it here rather than shelling out to its own Makefile keeps this a
# single cmake --build away from a working binary.
add_library(udpsclient STATIC ${CCLIENT_DIR}/udps_client.c)
target_include_directories(udpsclient PUBLIC ${CCLIENT_DIR})
target_compile_options(udpsclient PRIVATE -Wall -Wextra -Wpedantic)
# ── System packages ───────────────────────────────────────────────────────────
find_package(OpenGL REQUIRED)
find_package(SDL2 QUIET CONFIG)
if(NOT SDL2_FOUND)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SDL2 REQUIRED sdl2)
add_library(SDL2::SDL2 INTERFACE IMPORTED)
target_include_directories(SDL2::SDL2 INTERFACE ${SDL2_INCLUDE_DIRS})
target_link_libraries(SDL2::SDL2 INTERFACE ${SDL2_LIBRARIES})
target_compile_options(SDL2::SDL2 INTERFACE ${SDL2_CFLAGS_OTHER})
endif()
# ── Dear ImGui + ImPlot ───────────────────────────────────────────────────────
include(FetchContent)
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.8
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(imgui)
FetchContent_Declare(implot
GIT_REPOSITORY https://github.com/epezent/implot.git
GIT_TAG v0.17
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(implot)
add_library(imgui_lib STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
${implot_SOURCE_DIR}/implot.cpp
${implot_SOURCE_DIR}/implot_items.cpp)
target_include_directories(imgui_lib PUBLIC
${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends ${implot_SOURCE_DIR})
target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL)
target_compile_options(imgui_lib PRIVATE -w)
# ── Bundled resources, borrowed read-only from the StreamHub client ───────────
set(RESOURCE_DIR ${STREAMHUB_DIR}/resources)
set(FONT_DIR ${RESOURCE_DIR}/fonts)
if(EXISTS ${FONT_DIR}/fa-solid-900.ttf AND EXISTS ${FONT_DIR}/IconsFontAwesome6.h)
set(HAVE_FONT_AWESOME TRUE)
message(STATUS "Font Awesome icons enabled (${FONT_DIR})")
else()
set(HAVE_FONT_AWESOME FALSE)
message(WARNING "Bundled Font Awesome missing — using ASCII icon fallbacks")
endif()
# Guarded: file(COPY) is a hard configure error on a missing source, which
# would defeat the fallback the block above just chose.
if(EXISTS ${FONT_DIR})
file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources)
endif()
# ── Core library: everything except main.cpp, so tests can link it ────────────
set(CORE_SOURCES
Decimate.cpp
)
add_library(udpscope_core STATIC ${CORE_SOURCES})
target_include_directories(udpscope_core PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${STREAMHUB_DIR}) # SignalBuffer.h, reused verbatim
target_link_libraries(udpscope_core PUBLIC udpsclient pthread)
target_compile_options(udpscope_core PRIVATE -Wall -Wextra -Wno-unused-parameter)
# ── Application ───────────────────────────────────────────────────────────────
set(APP_SOURCES
main.cpp
)
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp)
add_executable(UDPScope ${APP_SOURCES})
target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL)
target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}")
if(HAVE_FONT_AWESOME)
target_include_directories(UDPScope PRIVATE ${FONT_DIR})
target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME)
endif()
target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter)
install(TARGETS UDPScope DESTINATION bin)
install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope)
endif()
# ── Tests ─────────────────────────────────────────────────────────────────────
if(UDPSCOPE_BUILD_TESTS)
FetchContent_Declare(googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.15.2
GIT_SHALLOW TRUE)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
file(GLOB TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cpp)
add_executable(udpscope_tests ${TEST_SOURCES})
target_link_libraries(udpscope_tests PRIVATE udpscope_core GTest::gtest_main)
target_compile_options(udpscope_tests PRIVATE -Wall -Wextra -Wno-unused-parameter)
enable_testing()
include(GoogleTest)
gtest_discover_tests(udpscope_tests)
endif()
Note the if(EXISTS main.cpp) guard: it lets Tasks 1–8 build and test with no GUI code present at all, and disappears in Task 9 when main.cpp lands.
- Step 6: Configure, then run the tests and watch them fail
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j
The first configure downloads ImGui, ImPlot and GoogleTest, so it needs network access and takes a minute.
Then:
./build/udpscope_tests
Expected on a first run before Decimate.cpp is written: a link error for MinMaxDecimate. Since Steps 3–4 already wrote it, the expected result here is PASS, 5 tests. If you want to see the red first, comment out Decimate.cpp in CORE_SOURCES, rebuild, observe the undefined-reference failure, then restore it.
- Step 7: Add the build directory to git ignore
Create Client/udpscope/.gitignore:
build/
compile_commands.json
- Step 8: Commit
git add Client/udpscope/CMakeLists.txt Client/udpscope/.gitignore \
Client/udpscope/Types.h Client/udpscope/Decimate.h \
Client/udpscope/Decimate.cpp Client/udpscope/tests/DecimateTest.cpp
git commit -m "feat(udpscope): build scaffold and min/max envelope decimation"
Task 2: Pane tree
The BSP layout that the whole UI hangs off. Framework-free, so all the fiddly geometry is settled before a single ImGui call exists.
Files:
- Create:
Client/udpscope/PaneTree.h - Create:
Client/udpscope/PaneTree.cpp - Create:
Client/udpscope/tests/PaneTreeTest.cpp - Modify:
Client/udpscope/CMakeLists.txt(addPaneTree.cpptoCORE_SOURCES)
Interfaces:
-
Consumes:
udpscope::Rect,udpscope::ColorfromTypes.h(Task 1). -
Produces:
enum class Orient { Columns, Rows }enum class VMode { Auto, Range, Manual }struct VScale { VMode mode; double div; double offset; }struct Assignment { std::string signalName; Color color; float lineWidth; VScale vs; }struct PaneNode { bool leaf; std::vector<Assignment> signals; Orient orient; double ratio; std::unique_ptr<PaneNode> a, b; }class PaneTreewithroot(),layout(),splitLeaf(),closeLeaf(),leafCount(),hitTestSplitter(),hitTestHandle()struct PaneTree::Placed { PaneNode* leaf; Rect rect; }struct PaneTree::Splitter { PaneNode* node; Rect rect; Orient orient; }enum class Handle { None, Left, Right, Top, Bottom, Close }constexpr double kMinPaneSize = 80.0;
-
Step 1: Write the failing test
Create Client/udpscope/tests/PaneTreeTest.cpp:
#include "PaneTree.h"
#include <gtest/gtest.h>
using namespace udpscope;
namespace {
const Rect kScreen{0.0, 0.0, 1000.0, 600.0};
std::vector<PaneTree::Placed> leavesOf(const PaneTree& tree, const Rect& area) {
std::vector<PaneTree::Placed> leaves;
std::vector<PaneTree::Splitter> splitters;
tree.layout(area, leaves, splitters);
return leaves;
}
} /* namespace */
TEST(PaneTree, StartsAsOneEmptyLeafFillingTheArea) {
PaneTree tree;
EXPECT_EQ(tree.leafCount(), 1u);
const auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 1u);
EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0);
EXPECT_TRUE(leaves[0].leaf->signals.empty());
}
TEST(PaneTree, SplittingIntoColumnsHalvesTheWidth) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
const auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 2u);
EXPECT_DOUBLE_EQ(leaves[0].rect.w, 500.0);
EXPECT_DOUBLE_EQ(leaves[1].rect.w, 500.0);
EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0);
EXPECT_DOUBLE_EQ(leaves[1].rect.x, 500.0);
}
TEST(PaneTree, SplittingIntoRowsHalvesTheHeight) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Rows);
const auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 2u);
EXPECT_DOUBLE_EQ(leaves[0].rect.h, 300.0);
EXPECT_DOUBLE_EQ(leaves[1].rect.y, 300.0);
EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
}
// The pane being split keeps its content; the new pane is the empty one.
TEST(PaneTree, SplitKeepsTheOriginalContentInTheFirstChild) {
PaneTree tree;
tree.root()->signals.push_back(Assignment{"Voltage", Color{}, 1.5f, VScale{}});
tree.splitLeaf(tree.root(), Orient::Columns);
const auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 2u);
ASSERT_EQ(leaves[0].leaf->signals.size(), 1u);
EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Voltage");
EXPECT_TRUE(leaves[1].leaf->signals.empty());
}
TEST(PaneTree, ClosingALeafGivesItsSpaceToTheSibling) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 2u);
leaves[1].leaf->signals.push_back(Assignment{"Keep", Color{}, 1.5f, VScale{}});
tree.closeLeaf(leaves[0].leaf);
EXPECT_EQ(tree.leafCount(), 1u);
leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 1u);
EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
ASSERT_EQ(leaves[0].leaf->signals.size(), 1u);
EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Keep");
}
TEST(PaneTree, RefusesToCloseTheLastLeaf) {
PaneTree tree;
tree.closeLeaf(tree.root());
EXPECT_EQ(tree.leafCount(), 1u);
}
// A pane in the middle of a 3x3 touches no window edge. It must still be
// splittable, which is why handles are inset inside the pane rather than
// keyed on the window border.
TEST(PaneTree, AnInteriorPaneIsStillSplittable) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Rows); // top / bottom
auto leaves = leavesOf(tree, kScreen);
tree.splitLeaf(leaves[1].leaf, Orient::Rows); // 3 rows
leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 3u);
PaneNode* middle = leaves[1].leaf;
tree.splitLeaf(middle, Orient::Columns);
leaves = leavesOf(tree, kScreen);
tree.splitLeaf(leaves[2].leaf, Orient::Columns);
EXPECT_EQ(tree.leafCount(), 5u);
}
TEST(PaneTree, LayoutReportsOneSplitterPerSplitNode) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
auto leaves = leavesOf(tree, kScreen);
tree.splitLeaf(leaves[0].leaf, Orient::Rows);
std::vector<PaneTree::Placed> out;
std::vector<PaneTree::Splitter> splitters;
tree.layout(kScreen, out, splitters);
EXPECT_EQ(out.size(), 3u);
EXPECT_EQ(splitters.size(), 2u);
}
TEST(PaneTree, RatioSurvivesALayoutRoundTrip) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
tree.setRatio(tree.root(), 0.25);
const auto leaves = leavesOf(tree, kScreen);
ASSERT_EQ(leaves.size(), 2u);
EXPECT_DOUBLE_EQ(leaves[0].rect.w, 250.0);
EXPECT_DOUBLE_EQ(leaves[1].rect.w, 750.0);
}
TEST(PaneTree, RatioIsClampedSoNeitherPaneGoesBelowTheMinimum) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
tree.setRatio(tree.root(), 0.001);
const auto leaves = leavesOf(tree, kScreen);
EXPECT_GE(leaves[0].rect.w, kMinPaneSize);
EXPECT_GE(leaves[1].rect.w, kMinPaneSize);
}
TEST(PaneTree, HitTestFindsTheSplitterBetweenTwoPanes) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Columns);
std::vector<PaneTree::Placed> leaves;
std::vector<PaneTree::Splitter> splitters;
tree.layout(kScreen, leaves, splitters);
ASSERT_EQ(splitters.size(), 1u);
const PaneTree::Splitter* hit = tree.hitTestSplitter(splitters, 500.0, 300.0);
ASSERT_NE(hit, nullptr);
EXPECT_EQ(hit->orient, Orient::Columns);
EXPECT_EQ(tree.hitTestSplitter(splitters, 100.0, 300.0), nullptr);
}
TEST(PaneTree, HitTestFindsInsetSplitHandlesAndTheCloseButton) {
const Rect pane{0.0, 0.0, 400.0, 300.0};
EXPECT_EQ(PaneTree::hitTestHandle(pane, 8.0, 150.0), Handle::Left);
EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 150.0), Handle::Right);
EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 8.0), Handle::Top);
EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 292.0), Handle::Bottom);
EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 8.0), Handle::Close);
EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 150.0), Handle::None);
}
- Step 2: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j
Expected: FAIL — PaneTree.h: No such file or directory.
- Step 3: Write the header
Create Client/udpscope/PaneTree.h:
/**
* @file PaneTree.h
* @brief Binary-space-partition layout of the plot area.
*
* Framework-free: no ImGui, no UDPS. The geometry and the hit-testing are the
* fiddly part of the pane UI and are unit-tested without a window.
*/
#pragma once
#include "Types.h"
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
/** Direction a node splits its rectangle in. */
enum class Orient { Columns, Rows };
/** Vertical scaling strategy for one trace. */
enum class VMode { Auto, Range, Manual };
struct VScale {
VMode mode = VMode::Auto;
double div = 1.0; /**< Units per division, Manual only. */
double offset = 0.0; /**< Centre value, Manual only. */
};
/** One signal drawn in one pane. Signals are named, never indexed. */
struct Assignment {
std::string signalName;
Color color;
float lineWidth = 1.5f;
VScale vs;
};
/** Smallest a pane may be squeezed to, in pixels. */
constexpr double kMinPaneSize = 80.0;
/** Thickness of the splitter drag zone and of the inset handles, in pixels. */
constexpr double kSplitterGrab = 6.0;
constexpr double kHandleSize = 18.0;
/** What the pointer is over inside a pane. */
enum class Handle { None, Left, Right, Top, Bottom, Close };
struct PaneNode {
bool leaf = true;
/* leaf only */
std::vector<Assignment> signals;
bool profilePane = false; /**< Holds vector signals, not time series. */
/* split only */
Orient orient = Orient::Columns;
double ratio = 0.5; /**< First child's share of the parent. */
std::unique_ptr<PaneNode> a, b;
};
class PaneTree {
public:
struct Placed { PaneNode* leaf; Rect rect; };
struct Splitter { PaneNode* node; Rect rect; Orient orient; };
PaneTree();
PaneNode* root() { return root_.get(); }
const PaneNode* root() const { return root_.get(); }
/** Replaces the whole tree, e.g. when loading a session. */
void setRoot(std::unique_ptr<PaneNode> node);
/**
* @brief Walk the tree, producing every leaf's rectangle and every split's
* drag zone.
*/
void layout(const Rect& area,
std::vector<Placed>& leaves,
std::vector<Splitter>& splitters) const;
/** Turn a leaf into a split; the original content stays in the first child. */
void splitLeaf(PaneNode* leaf, Orient orient);
/** Replace the leaf's parent with its sibling. No-op on the last leaf. */
void closeLeaf(PaneNode* leaf);
void setRatio(PaneNode* split, double ratio);
size_t leafCount() const;
/** @return the splitter under the point, or nullptr. */
const Splitter* hitTestSplitter(const std::vector<Splitter>& splitters,
double px, double py) const;
/**
* @brief Which inset handle of @p pane the point is over.
*
* Handles sit inside the pane so they never overlap the splitter drag zone,
* and every pane has all four regardless of whether it touches a window
* edge — a pane in the middle of a 3x3 touches none.
*/
static Handle hitTestHandle(const Rect& pane, double px, double py);
private:
static void layoutNode(PaneNode* node, const Rect& r,
std::vector<Placed>& leaves,
std::vector<Splitter>& splitters);
static size_t countLeaves(const PaneNode* node);
static PaneNode* findParent(PaneNode* node, const PaneNode* child);
static double clampRatio(double ratio, double extent);
std::unique_ptr<PaneNode> root_;
};
} /* namespace udpscope */
- Step 4: Write the implementation
Create Client/udpscope/PaneTree.cpp:
#include "PaneTree.h"
#include <algorithm>
namespace udpscope {
PaneTree::PaneTree() : root_(new PaneNode()) {}
void PaneTree::setRoot(std::unique_ptr<PaneNode> node) {
if (node) { root_ = std::move(node); }
}
double PaneTree::clampRatio(double ratio, double extent) {
if (extent <= 2.0 * kMinPaneSize) {
return 0.5; /* Too small to honour the minimum on both sides. */
}
const double lo = kMinPaneSize / extent;
return std::min(std::max(ratio, lo), 1.0 - lo);
}
void PaneTree::layoutNode(PaneNode* node, const Rect& r,
std::vector<Placed>& leaves,
std::vector<Splitter>& splitters) {
if (node == nullptr) { return; }
if (node->leaf) {
leaves.push_back(Placed{node, r});
return;
}
if (node->orient == Orient::Columns) {
const double ratio = clampRatio(node->ratio, r.w);
const double wA = r.w * ratio;
layoutNode(node->a.get(), Rect{r.x, r.y, wA, r.h}, leaves, splitters);
layoutNode(node->b.get(), Rect{r.x + wA, r.y, r.w - wA, r.h}, leaves, splitters);
splitters.push_back(Splitter{
node,
Rect{r.x + wA - kSplitterGrab * 0.5, r.y, kSplitterGrab, r.h},
Orient::Columns});
} else {
const double ratio = clampRatio(node->ratio, r.h);
const double hA = r.h * ratio;
layoutNode(node->a.get(), Rect{r.x, r.y, r.w, hA}, leaves, splitters);
layoutNode(node->b.get(), Rect{r.x, r.y + hA, r.w, r.h - hA}, leaves, splitters);
splitters.push_back(Splitter{
node,
Rect{r.x, r.y + hA - kSplitterGrab * 0.5, r.w, kSplitterGrab},
Orient::Rows});
}
}
void PaneTree::layout(const Rect& area,
std::vector<Placed>& leaves,
std::vector<Splitter>& splitters) const {
leaves.clear();
splitters.clear();
layoutNode(root_.get(), area, leaves, splitters);
}
void PaneTree::splitLeaf(PaneNode* leaf, Orient orient) {
if (leaf == nullptr || !leaf->leaf) { return; }
/* Move the existing content into a new first child; the second is empty. */
std::unique_ptr<PaneNode> first(new PaneNode());
first->signals = std::move(leaf->signals);
first->profilePane = leaf->profilePane;
std::unique_ptr<PaneNode> second(new PaneNode());
leaf->leaf = false;
leaf->orient = orient;
leaf->ratio = 0.5;
leaf->signals.clear();
leaf->a = std::move(first);
leaf->b = std::move(second);
}
PaneNode* PaneTree::findParent(PaneNode* node, const PaneNode* child) {
if (node == nullptr || node->leaf) { return nullptr; }
if (node->a.get() == child || node->b.get() == child) { return node; }
if (PaneNode* p = findParent(node->a.get(), child)) { return p; }
return findParent(node->b.get(), child);
}
void PaneTree::closeLeaf(PaneNode* leaf) {
if (leaf == nullptr || !leaf->leaf) { return; }
PaneNode* parent = findParent(root_.get(), leaf);
if (parent == nullptr) {
return; /* The root is the only leaf; a scope with no pane is useless. */
}
std::unique_ptr<PaneNode> survivor =
(parent->a.get() == leaf) ? std::move(parent->b) : std::move(parent->a);
/* Collapse the parent into the survivor in place, so the parent pointer
* held by any caller stays valid. */
parent->leaf = survivor->leaf;
parent->signals = std::move(survivor->signals);
parent->profilePane = survivor->profilePane;
parent->orient = survivor->orient;
parent->ratio = survivor->ratio;
parent->a = std::move(survivor->a);
parent->b = std::move(survivor->b);
}
void PaneTree::setRatio(PaneNode* split, double ratio) {
if (split != nullptr && !split->leaf) {
split->ratio = std::min(std::max(ratio, 0.0), 1.0);
}
}
size_t PaneTree::countLeaves(const PaneNode* node) {
if (node == nullptr) { return 0; }
if (node->leaf) { return 1; }
return countLeaves(node->a.get()) + countLeaves(node->b.get());
}
size_t PaneTree::leafCount() const { return countLeaves(root_.get()); }
const PaneTree::Splitter* PaneTree::hitTestSplitter(
const std::vector<Splitter>& splitters, double px, double py) const {
for (const Splitter& s : splitters) {
if (s.rect.contains(px, py)) { return &s; }
}
return nullptr;
}
Handle PaneTree::hitTestHandle(const Rect& pane, double px, double py) {
if (!pane.contains(px, py)) { return Handle::None; }
const double relX = px - pane.x;
const double relY = py - pane.y;
const double midY = pane.h * 0.5;
const double midX = pane.w * 0.5;
const double half = kHandleSize * 0.5;
/* Close sits in the top-right corner and wins over the edge handles. */
if (relX >= pane.w - kHandleSize && relY <= kHandleSize) {
return Handle::Close;
}
if (relX <= kHandleSize && std::abs(relY - midY) <= half * 3.0) {
return Handle::Left;
}
if (relX >= pane.w - kHandleSize && std::abs(relY - midY) <= half * 3.0) {
return Handle::Right;
}
if (relY <= kHandleSize && std::abs(relX - midX) <= half * 3.0) {
return Handle::Top;
}
if (relY >= pane.h - kHandleSize && std::abs(relX - midX) <= half * 3.0) {
return Handle::Bottom;
}
return Handle::None;
}
} /* namespace udpscope */
Add #include <cmath> at the top of PaneTree.cpp for std::abs on doubles.
- Step 5: Register the source with CMake
In Client/udpscope/CMakeLists.txt, change CORE_SOURCES to:
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
)
- Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneTree*'
Expected: PASS, 12 tests.
- Step 7: Commit
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/tests/PaneTreeTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): BSP pane tree with split, close and hit-testing"
Task 3: Time base
Maps a producer clock onto wall clock. Two pieces: a one-off offset with drift-triggered recalibration, and a least-squares fit that recovers the hrt tick rate without assuming the client runs on the producer's host.
Files:
- Create:
Client/udpscope/TimeBase.h - Create:
Client/udpscope/TimeBase.cpp - Create:
Client/udpscope/tests/TimeBaseTest.cpp - Modify:
Client/udpscope/CMakeLists.txt(addTimeBase.cpp)
Interfaces:
-
Consumes: nothing.
-
Produces:
class ClockOffset—double map(double producerSec, double wallSec),bool valid() const,void reset(),static constexpr double kRecalibThresholdS = 0.5class HrtRateFit—void add(uint64_t hrt, double wallSec),bool ready() const,double ticksPerSecond() const,double toSeconds(uint64_t hrt) const,void reset(),static constexpr size_t kMinSamples = 32double TimeSignalScale(uint8_t typeCode)
-
Step 1: Write the failing test
Create Client/udpscope/tests/TimeBaseTest.cpp:
#include "TimeBase.h"
#include <gtest/gtest.h>
using namespace udpscope;
TEST(ClockOffset, MapsTheFirstReadingOntoWallClockExactly) {
ClockOffset off;
EXPECT_FALSE(off.valid());
const double wall = 1756291200.5;
EXPECT_DOUBLE_EQ(off.map(10.0, wall), wall);
EXPECT_TRUE(off.valid());
}
// Network delay jitters the arrival time. If the offset chased every packet
// the whole trace would wobble, so it is latched and only corrected on real
// drift.
TEST(ClockOffset, HoldsTheOffsetThroughSmallArrivalJitter) {
ClockOffset off;
off.map(10.0, 1000.0); // offset = 990
// Arrival wanders either side of the prediction. wallSec is a local receive
// timestamp, so it only ever advances — jitter shows up as the gap growing
// and shrinking, never as the clock going backwards.
EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0); // +0.02 late
EXPECT_DOUBLE_EQ(off.map(12.0, 1001.97), 1002.0); // -0.03 early
}
// The threshold has to be symmetric. A producer whose clock steps FORWARD (an
// NTP correction on the producer's host, say) puts the prediction permanently
// ahead of the wall clock — a one-sided "recalibrate only when wall is ahead"
// test never fires for it, and the trace sits in the future for the rest of the
// run.
TEST(ClockOffset, RecalibratesWhenTheProducerClockJumpsForward) {
ClockOffset off;
off.map(10.0, 1000.0); // offset = 990
// Producer leaps 100 s ahead while only 1 s of wall time passes.
EXPECT_DOUBLE_EQ(off.map(111.0, 1001.0), 1001.0);
}
TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) {
ClockOffset off;
off.map(10.0, 1000.0); // offset = 990
/* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */
const double mapped = off.map(11.0, 1006.0);
EXPECT_DOUBLE_EQ(mapped, 1006.0);
}
TEST(ClockOffset, ResetForgetsTheCalibration) {
ClockOffset off;
off.map(10.0, 1000.0);
off.reset();
EXPECT_FALSE(off.valid());
EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0);
}
// The tick rate of the producer's high-resolution timer is not carried by the
// protocol, and StreamHub's trick of using the local MARTe timer frequency only
// works on the producer's own host. Recover it from the data instead.
TEST(HrtRateFit, RecoversAKnownTickRate) {
HrtRateFit fit;
const double ticksPerSec = 2.5e9;
EXPECT_FALSE(fit.ready());
for (int i = 0; i < 64; i++) {
const double wall = 1000.0 + i * 0.01;
fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
}
ASSERT_TRUE(fit.ready());
EXPECT_NEAR(fit.ticksPerSecond(), ticksPerSec, ticksPerSec * 1e-6);
}
TEST(HrtRateFit, IsNotReadyBeforeTheMinimumSampleCount) {
HrtRateFit fit;
for (size_t i = 0; i < HrtRateFit::kMinSamples - 1; i++) {
fit.add(static_cast<uint64_t>(i) * 1000000u, 1000.0 + i * 0.001);
}
EXPECT_FALSE(fit.ready());
fit.add(static_cast<uint64_t>(HrtRateFit::kMinSamples) * 1000000u,
1000.0 + HrtRateFit::kMinSamples * 0.001);
EXPECT_TRUE(fit.ready());
}
TEST(HrtRateFit, ToSecondsUsesTheFittedRate) {
HrtRateFit fit;
const double ticksPerSec = 1.0e9;
for (int i = 0; i < 64; i++) {
const double wall = 500.0 + i * 0.005;
fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
}
ASSERT_TRUE(fit.ready());
EXPECT_NEAR(fit.toSeconds(2000000000ull), 2.0, 1e-4);
}
TEST(HrtRateFit, SurvivesAStalledClock) {
HrtRateFit fit;
for (int i = 0; i < 64; i++) {
fit.add(12345u, 1000.0 + i * 0.01); /* hrt never advances */
}
/* A degenerate fit must not produce a rate that would divide by zero. */
if (fit.ready()) {
EXPECT_GT(fit.ticksPerSecond(), 0.0);
}
}
TEST(TimeSignalScale, UsesNanosecondsForUint64AndMicrosecondsOtherwise) {
EXPECT_DOUBLE_EQ(TimeSignalScale(6 /* UDPS_T_UINT64 */), 1.0e-9);
EXPECT_DOUBLE_EQ(TimeSignalScale(9 /* UDPS_T_FLOAT64 */), 1.0e-6);
EXPECT_DOUBLE_EQ(TimeSignalScale(4 /* UDPS_T_UINT32 */), 1.0e-6);
}
- Step 2: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j
Expected: FAIL — TimeBase.h: No such file or directory.
- Step 3: Write the header
Create Client/udpscope/TimeBase.h:
/**
* @file TimeBase.h
* @brief Producer-clock to wall-clock reconstruction.
*
* Framework-free. A UDPS stream's accurate timestamps come from a producer
* clock — either a declared time signal or the packet's embedded hrt — and both
* need mapping onto the client's wall clock before they can be plotted.
*/
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
namespace udpscope {
/**
* @brief Seconds per count of a time signal, from its type code.
*
* The protocol carries uint64 time signals in nanoseconds and everything else
* in microseconds; this mirrors UDPSourceSession so the two agree on a stream.
*/
double TimeSignalScale(uint8_t typeCode);
/**
* @brief A latched producer-to-wall offset.
*
* Established from the first sample and then held, so network jitter does not
* wobble the trace. Only a drift beyond kRecalibThresholdS — a producer restart
* or re-phase, not delivery noise — forces a new calibration.
*/
class ClockOffset {
public:
static constexpr double kRecalibThresholdS = 0.5;
/** @return producerSec mapped onto wall clock. */
double map(double producerSec, double wallSec);
bool valid() const { return valid_; }
void reset() { valid_ = false; offset_ = 0.0; }
double offset() const { return offset_; }
private:
double offset_ = 0.0;
bool valid_ = false;
};
/**
* @brief Recovers the producer's hrt tick rate by least squares against arrival
* time.
*
* The protocol does not carry the tick rate, and StreamHub's approach of using
* the local MARTe HighResolutionTimer frequency is only valid when the client
* runs on the producer's host. A remote bench scope cannot assume that, so the
* rate is measured: hrt against recv_time is a straight line whose slope is
* ticks per second.
*/
class HrtRateFit {
public:
static constexpr size_t kMinSamples = 32;
static constexpr size_t kWindow = 256;
void add(uint64_t hrt, double wallSec);
bool ready() const { return n_ >= kMinSamples && rate_ > 0.0; }
double ticksPerSecond() const { return rate_; }
double toSeconds(uint64_t hrt) const;
void reset();
private:
void refit();
struct Sample { double hrt; double wall; };
std::deque<Sample> samples_;
size_t n_ = 0;
double rate_ = 0.0;
};
} /* namespace udpscope */
- Step 4: Write the implementation
Create Client/udpscope/TimeBase.cpp:
#include "TimeBase.h"
#include <cmath>
namespace udpscope {
/* UDPS_T_UINT64 == 6 in Common/UDP/UDPSProtocol.h. Spelled numerically so this
* translation unit stays free of the C client header. */
static constexpr uint8_t kTypeUint64 = 6u;
double TimeSignalScale(uint8_t typeCode) {
return (typeCode == kTypeUint64) ? 1.0e-9 : 1.0e-6;
}
double ClockOffset::map(double producerSec, double wallSec) {
if (!valid_ || std::fabs((offset_ + producerSec) - wallSec) > kRecalibThresholdS) {
offset_ = wallSec - producerSec;
valid_ = true;
}
return offset_ + producerSec;
}
void HrtRateFit::reset() {
samples_.clear();
n_ = 0;
rate_ = 0.0;
}
void HrtRateFit::add(uint64_t hrt, double wallSec) {
samples_.push_back(Sample{static_cast<double>(hrt), wallSec});
if (samples_.size() > kWindow) { samples_.pop_front(); }
n_++;
if (n_ >= kMinSamples) { refit(); }
}
void HrtRateFit::refit() {
const size_t n = samples_.size();
if (n < 2) { return; }
/* Least squares slope of hrt against wall time. Both are subtracted from
* their first value first: raw hrt counts and epoch seconds are large
* enough that the naive sums lose precision. */
const double h0 = samples_.front().hrt;
const double w0 = samples_.front().wall;
double sw = 0.0, sh = 0.0, sww = 0.0, swh = 0.0;
for (const Sample& s : samples_) {
const double w = s.wall - w0;
const double h = s.hrt - h0;
sw += w;
sh += h;
sww += w * w;
swh += w * h;
}
const double dn = static_cast<double>(n);
const double denom = dn * sww - sw * sw;
if (std::fabs(denom) < 1e-12) { return; }
const double slope = (dn * swh - sw * sh) / denom;
if (slope > 0.0 && std::isfinite(slope)) { rate_ = slope; }
}
double HrtRateFit::toSeconds(uint64_t hrt) const {
if (rate_ <= 0.0) { return 0.0; }
return static_cast<double>(hrt) / rate_;
}
} /* namespace udpscope */
- Step 5: Register the source with CMake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
)
- Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ClockOffset*:HrtRateFit*:TimeSignalScale*'
Expected: PASS, 9 tests.
- Step 7: Commit
git add Client/udpscope/TimeBase.h Client/udpscope/TimeBase.cpp \
Client/udpscope/tests/TimeBaseTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): producer-clock calibration and hrt tick-rate fit"
Task 4: Frame decoder — per-element timestamps
Spec §5, the part the C library deliberately does not do for you. udps_frame_element_time() is an arrival-anchored estimate; relying on it renders bursty delivery as a sawtooth. This reproduces UDPSourceSession.cpp's rules on top of the C API.
Files:
- Create:
Client/udpscope/FrameDecoder.h - Create:
Client/udpscope/FrameDecoder.cpp - Create:
Client/udpscope/tests/FrameDecoderTest.cpp - Modify:
Client/udpscope/Types.h(addSignalMeta,FrameView, protocol constants) - Modify:
Client/udpscope/CMakeLists.txt(addFrameDecoder.cpp)
Interfaces:
-
Consumes:
ClockOffset,HrtRateFit,TimeSignalScale(Task 3). -
Produces:
udpscope::SignalMetawithnumElements(),hasTimeSignal(uint32_t),isVectorProfile()udpscope::FrameView— a non-owning mirror ofudps_frame_tconstexpr uint8_t kTimePacket/kTimeFullArray/kTimeFirstSample/kTimeLastSampleconstexpr uint32_t kNoTimeSignalclass FrameDecoderwithsetSignals(),beginFrame(),timestamps(),reset()
-
Step 1: Extend the shared types
Append to Client/udpscope/Types.h, inside namespace udpscope:
/* Protocol constants, spelled out rather than included, so the framework-free
* modules stay independent of udps_client.h. They mirror Common/UDP/UDPSProtocol.h. */
constexpr uint8_t kTimePacket = 0;
constexpr uint8_t kTimeFullArray = 1;
constexpr uint8_t kTimeFirstSample = 2;
constexpr uint8_t kTimeLastSample = 3;
constexpr uint32_t kNoTimeSignal = 0xFFFFFFFFu;
/** Framework-free mirror of udps_signal_t, plus UI state. */
struct SignalMeta {
std::string name;
uint8_t typeCode = 255;
uint8_t quantType = 0;
uint32_t numRows = 1;
uint32_t numCols = 1;
double rangeMin = 0.0;
double rangeMax = 0.0;
uint8_t timeMode = kTimePacket;
double samplingRate = 0.0;
uint32_t timeSignalIdx = kNoTimeSignal;
std::string unit;
/** User override: treat an ambiguous PACKET array as a profile, not a burst. */
bool profileOverride = false;
uint32_t numElements() const {
const uint64_t n = static_cast<uint64_t>(numRows ? numRows : 1u) *
static_cast<uint64_t>(numCols ? numCols : 1u);
return n == 0u ? 1u : static_cast<uint32_t>(n);
}
bool hasTimeSignal(uint32_t numSignals) const {
return timeSignalIdx != kNoTimeSignal && timeSignalIdx < numSignals;
}
/**
* @brief True when this array should be plotted against element index
* rather than unrolled onto the time axis.
*
* Only PACKET arrays are ambiguous: the producer stamped the whole datagram
* with one time, which is what a genuine vector looks like and also what a
* burst carrying no time metadata looks like. Default is burst, matching
* UDPSourceSession, with this flag as the user's override.
*/
bool isVectorProfile() const {
return profileOverride && numElements() > 1u && timeMode == kTimePacket;
}
};
/**
* @brief Non-owning mirror of udps_frame_t.
*
* Kept separate from the C struct so FrameDecoder can be tested with plain
* arrays and no socket. Points at memory owned by the caller.
*/
struct FrameView {
uint32_t counter = 0;
uint64_t hrt = 0;
double recvTime = 0.0;
uint32_t numSamples = 1;
uint32_t numSignals = 0;
const double* const* values = nullptr; /**< values[i][0..counts[i]) */
const uint32_t* counts = nullptr;
};
- Step 2: Write the failing test
Create Client/udpscope/tests/FrameDecoderTest.cpp:
#include "FrameDecoder.h"
#include <gtest/gtest.h>
#include <vector>
using namespace udpscope;
namespace {
/** Builds a FrameView over vectors the test owns. */
struct FrameBuilder {
std::vector<std::vector<double>> storage;
std::vector<const double*> ptrs;
std::vector<uint32_t> counts;
FrameView view;
void addSignal(std::vector<double> vals) {
storage.push_back(std::move(vals));
}
const FrameView& build(uint64_t hrt, double recvTime, uint32_t numSamples = 1) {
ptrs.clear();
counts.clear();
for (const auto& s : storage) {
ptrs.push_back(s.data());
counts.push_back(static_cast<uint32_t>(s.size()));
}
view.hrt = hrt;
view.recvTime = recvTime;
view.numSamples = numSamples;
view.numSignals = static_cast<uint32_t>(storage.size());
view.values = ptrs.data();
view.counts = counts.data();
return view;
}
};
SignalMeta burst(const char* name, uint8_t timeMode, double rate,
uint32_t elems, uint32_t timeIdx) {
SignalMeta m;
m.name = name;
m.typeCode = 8; /* float32 */
m.numRows = elems;
m.numCols = 1;
m.timeMode = timeMode;
m.samplingRate = rate;
m.timeSignalIdx = timeIdx;
return m;
}
SignalMeta timeSignal(const char* name, uint32_t elems) {
SignalMeta m;
m.name = name;
m.typeCode = 6; /* uint64 -> nanoseconds */
m.numRows = elems;
m.numCols = 1;
return m;
}
} /* namespace */
TEST(FrameDecoder, FullArrayTakesOneStampPerElementFromTheTimeSignal) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFullArray, 1000.0, 4, 1),
timeSignal("Time", 4)});
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
/* Nanoseconds: 5.000, 5.001, 5.002, 5.003 s of producer time. */
fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
const FrameView& f = fb.build(0, 1000.0);
dec.beginFrame(f);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
/* Element 0 lands on the arrival time; the rest keep the producer spacing. */
EXPECT_NEAR(ts[0], 1000.000, 1e-9);
EXPECT_NEAR(ts[1], 1000.001, 1e-9);
EXPECT_NEAR(ts[2], 1000.002, 1e-9);
EXPECT_NEAR(ts[3], 1000.003, 1e-9);
}
TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFirstSample, 1000.0, 4, 1),
timeSignal("Time", 1)});
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9});
const FrameView& f = fb.build(0, 2000.0);
dec.beginFrame(f);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
EXPECT_NEAR(ts[0], 2000.000, 1e-9);
EXPECT_NEAR(ts[3], 2000.003, 1e-9);
}
TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
timeSignal("Time", 1)});
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9});
const FrameView& f = fb.build(0, 3000.0);
dec.beginFrame(f);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
EXPECT_NEAR(ts[3], 3000.000, 1e-9);
EXPECT_NEAR(ts[0], 3000.000 - 0.003, 1e-9);
}
TEST(FrameDecoder, PlainScalarUsesArrivalTime) {
FrameDecoder dec;
SignalMeta m;
m.name = "Level";
m.typeCode = 9;
dec.setSignals({m});
FrameBuilder fb;
fb.addSignal({42.0});
const FrameView& f = fb.build(0, 1234.5);
dec.beginFrame(f);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 1u);
EXPECT_DOUBLE_EQ(ts[0], 1234.5);
}
// This is the failure UDPSourceSession.cpp:560 documents. The kernel delivers
// two queued datagrams microseconds apart even though each carries 10 ms of
// signal. Dating from arrival crams the second packet's samples into that gap
// and the trace becomes a sawtooth; dating from the producer hrt does not.
TEST(FrameDecoder, AccumulatedScalarSurvivesBurstyDelivery) {
FrameDecoder dec;
SignalMeta m;
m.name = "Acc";
m.typeCode = 9;
m.numRows = 1;
m.samplingRate = 1000.0; /* 1 kHz, 10 samples = 10 ms per packet */
dec.setSignals({m});
const double ticks = 1.0e9;
std::vector<double> all;
for (int p = 0; p < 40; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, static_cast<double>(p)));
const double producerSec = 100.0 + p * 0.010;
/* Packets 20+ arrive in a burst, all within 50 us of each other. */
const double arrival = (p < 20) ? (500.0 + p * 0.010)
: (500.2 + (p - 20) * 0.00005);
const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
arrival, 10);
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) {
all.insert(all.end(), ts.begin(), ts.end());
}
}
ASSERT_GT(all.size(), 300u);
for (size_t i = 1; i < all.size(); i++) {
EXPECT_GT(all[i], all[i - 1]) << "non-monotonic at " << i;
EXPECT_NEAR(all[i] - all[i - 1], 0.001, 2e-4)
<< "spacing collapsed at " << i << " (sawtooth)";
}
}
// ADDED in the Task 4 review rounds. FrameBuilder::build() gained a `counter`
// parameter for these; leaving it at zero, as the original harness did, hides
// the counter rules entirely. Two shared helpers:
//
// /** 1 kHz accumulated scalar: 10 samples = 10 ms per packet. */
// SignalMeta accSignal();
// /** Ten contiguous bursts, counters 1..10, leaving ts[9] == 500.090. */
// void primeTenBursts(FrameDecoder&, std::vector<double>& ts, bool withCounter);
// The counterweight to the test above. Chaining bursts to suppress arrival
// jitter is only safe if loss is accounted for. The wire says exactly how much
// is missing, so no estimate is needed — and this test deliberately makes
// arrival time a LIAR (200 ms off) to prove the reconstruction comes from the
// counter and not from when the packet landed.
TEST(FrameDecoder, AccumulatedScalarReinstatesLostPacketsFromTheCounterGap) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
/* Counter 111 after 10: 100 packets lost, 1000 samples, exactly 1 s. */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 501.300, 10, 111u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
/* Chaining blindly gives 500.091; anchoring on arrival gives 501.291. */
EXPECT_NEAR(ts[0], 501.091, 1e-9);
EXPECT_NEAR(ts[9], 501.100, 1e-9);
}
// A producer that never advances the counter leaves nothing to reconstruct
// from. Arrival time is then the better of two bad answers.
TEST(FrameDecoder, AccumulatedScalarResyncsOnArrivalWhenTheCounterSaysNothing) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/false);
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 501.100, 10, 0u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
EXPECT_NEAR(ts[0], 501.091, 1e-9);
EXPECT_NEAR(ts[9], 501.100, 1e-9);
}
// Re-anchoring must never move a signal's timestamps backwards: the ring, the
// trigger and the exporter all assume they increase. Here the counter claims a
// 20 s hole while the packet arrives BEFORE our timeline reached.
TEST(FrameDecoder, AccumulatedScalarNeverStepsBackwardsWhenResyncing) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
const double prevEnd = ts[9];
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 500.000, 10, 2010u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
/* Arrival (500.000) is behind our timeline, so there is nothing to spread
* into; the burst is squeezed instead, which bleeds the lead off while still
* moving strictly forwards. The excess (90 ms) is nine nominal burst widths,
* so the squeeze hits its floor of 0.05 and the step is 50 us. */
EXPECT_NEAR(ts[0], 500.09005, 1e-9);
EXPECT_GT(ts[0], prevEnd) << "resync stepped backwards over the previous burst";
for (size_t i = 1; i < ts.size(); i++) {
EXPECT_GT(ts[i], ts[i - 1]);
}
}
// When the chain must be abandoned but arrival lies just ahead of where the last
// burst ended, the correction COMPRESSES this one burst rather than stepping
// back. Rejecting the correction instead would be one-directional — `predicted`
// is never below lastEmittedEnd + dt — so a fast timeline could never return.
TEST(FrameDecoder, AccumulatedScalarCompressesOneBurstRatherThanStepBack) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
/* The compress branch needs the prediction to be rejected while arrival
* still sits between the previous burst's end and one burst beyond it —
* which a plain rate mismatch cannot produce, since the prediction is then
* only a burst away from arrival. It takes a fabricated loss: this gap
* claims 900000 lost packets, putting the prediction 2.5 hours out, while
* the packet itself lands 5 ms after the last burst ended so its arrival
* anchor (500.086) falls just behind that end. */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 500.095, 10, 900011u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
EXPECT_GT(ts[0], 500.090) << "compressed burst must still start after the last one";
EXPECT_NEAR(ts[9], 500.095, 1e-9) << "and end exactly on arrival";
EXPECT_NEAR(ts[1] - ts[0], 0.0005, 1e-9) << "spread over the available room";
}
// The whole point of compressing: a declared SamplingRate is a hand-written
// config value, and even a correct one is measured against the producer host's
// crystal, not ours. Tens of ppm of difference is certain over a long session,
// so the reconstructed timeline WILL run away from the wall clock. It has to be
// pulled back, and it has to stay monotonic while that happens.
TEST(FrameDecoder, AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever) {
FrameDecoder dec;
dec.setSignals({accSignal()}); /* declares 1 kHz */
/* The producer really runs 1 % fast: 10 samples take 9.9 ms of wall time,
* so a chain stepping the declared 10 ms per packet gains 0.1 ms every
* packet. This is the direction re-anchoring alone cannot fix: arrival is
* always BEHIND the chain, so anchoring on it would step backwards and is
* refused. Only compression pulls the timeline back. */
double worstLead = 0.0;
double lastEnd = 0.0;
for (int p = 0; p < 20000; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const double arrival = 500.0 + p * 0.0099;
const FrameView& f =
fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(f, 0, ts));
for (size_t i = 0; i < ts.size(); i++) {
ASSERT_GT(ts[i], lastEnd) << "timeline went backwards at packet " << p;
lastEnd = ts[i];
}
worstLead = std::max(worstLead, ts[9] - arrival);
}
/* Unchecked, 20000 packets at 0.1 ms each would put the trace 2 s ahead. */
EXPECT_LT(worstLead, 0.6) << "timeline drifted " << worstLead << " s ahead";
}
// The C client de-duplicates fragments but not whole unfragmented updates, so a
// host subscribed on two interfaces sees each datagram twice. Emitting the
// repeat would double the values and advance time by a burst that never was.
TEST(FrameDecoder, AccumulatedScalarDropsADuplicatedDatagram) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
const double endBefore = ts[9];
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& dup = fb.build(0, 500.1001, 10, 10u); /* counter 10 again */
dec.beginFrame(dup);
EXPECT_FALSE(dec.timestamps(dup, 0, ts));
/* And the drop must not have disturbed the chain: the genuine next packet
* still lands one period after burst 10 ended. */
const FrameView& next = fb.build(0, 500.109, 10, 11u);
dec.beginFrame(next);
ASSERT_TRUE(dec.timestamps(next, 0, ts));
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-9);
}
// A producer restart returns the counter to zero mid-stream. The unsigned gap
// then wraps to near 2^32; the loss it implies puts the chained prediction
// centuries out, the arrival backstop rejects it, and arrival becomes the only
// usable reference.
TEST(FrameDecoder, AccumulatedScalarSurvivesAProducerRestart) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
const double prevEnd = ts[9];
/* Restarted producer: counter 1 again, and the outage lasted 3 s. */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 503.100, 10, 1u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
/* Reading the wrapped gap as a loss count would claim ~4.3e9 lost packets,
* some 5e8 seconds of fabricated signal. */
EXPECT_NEAR(ts[9], 503.100, 1e-9) << "restart must re-anchor on arrival";
EXPECT_GT(ts[0], prevEnd);
}
// Accumulate mode flushes on a timer, so a short cycle legitimately delivers a
// single sample between two full bursts. That packet must stay on the chain: if
// it fell through to the plain-scalar rule it would be dated from arrival while
// its neighbours are chained, and would leave lastCounter behind so the next
// real burst read the skip as a lost datagram.
TEST(FrameDecoder, AccumulatedScalarKeepsShortFlushesOnTheChain) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
double last = ts[9];
uint32_t counter = 10u;
double arrival = 500.090;
for (int p = 0; p < 500; p++) {
/* Alternating 10-sample and 1-sample flushes, 10 ms and 1 ms of signal. */
const uint32_t n = (p % 2 == 0) ? 1u : 10u;
arrival += 0.001 * static_cast<double>(n);
FrameBuilder fb;
fb.addSignal(std::vector<double>(n, 1.0));
const FrameView& f = fb.build(0, arrival, n, ++counter);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts)) << "short flush dropped at " << p;
ASSERT_EQ(ts.size(), n);
for (size_t i = 0; i < ts.size(); i++) {
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
/* Contiguous: no phantom loss was ever reinstated. */
ASSERT_NEAR(ts[i] - last, 0.001, 1e-6) << "gap opened at packet " << p;
last = ts[i];
}
}
}
TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared) {
FrameDecoder dec;
SignalMeta m;
m.name = "Acc";
m.typeCode = 9;
m.samplingRate = 0.0; /* undeclared */
dec.setSignals({m});
const double ticks = 1.0e9;
std::vector<double> last;
for (int p = 0; p < 40; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* 25 ms per packet, deliberately NOT 10: at 10 the expected 1 ms period
* equals kDefaultDt, so a decoder that never derived anything and just
* returned the default would pass a test named for the derivation. */
const double producerSec = 100.0 + p * 0.025;
/* Zero-mean arrival jitter, so the rate fit still converges but no
* single arrival GAP is right. Without it, uniform arrivals make
* packetBurst and the hrt path return the same number by construction
* and the test cannot tell which branch answered. */
const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
700.0 + p * 0.025 + jitter[p % 4], 10,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) { last = ts; }
}
ASSERT_EQ(last.size(), 10u);
/* 25 ms of producer time across 10 samples is a 2.5 ms period, whatever the
* datagrams did on the way over. Arrival-spanning the last gap (22 ms)
* would give 2.2 ms; defaulting would give 1 ms. */
EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5);
}
// The trap the hrt path fell into once: positioning each burst at
// hrt / ticksPerSecond(). hrt counts from the PRODUCER'S BOOT, so it is already
// ~1e11 ticks for a machine that has been up a day, while the rate is refitted
// on every packet and wobbles by parts in 1e4 as arrival jitter enters and
// leaves the window. The wobble arrives multiplied by that whole epoch — tens of
// milliseconds, in both directions — so bursts land out of order. The producer
// clock here is EXACT; every timestamp inversion this test can see comes from
// the client's own arithmetic.
TEST(FrameDecoder, AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot) {
FrameDecoder dec;
SignalMeta m;
m.name = "Acc";
m.typeCode = 9;
m.samplingRate = 0.0; /* undeclared: the hrt path */
dec.setSignals({m});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks); /* up 1 day */
double last = 0.0;
uint32_t seed = 12345u;
for (int p = 0; p < 20000; p++) { /* 500 s of stream */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* Exact producer clock: 25 ms per packet, 2.5 ms per sample. */
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
/* Ordinary scheduling jitter, +/- 1 ms, zero mean. */
seed = seed * 1103515245u + 12345u;
const double jitter = (static_cast<double>((seed >> 16) & 0xFFFFu) /
65535.0 - 0.5) * 0.002;
const FrameView& f = fb.build(hrt, 700.0 + p * 0.025 + jitter, 10,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (size_t i = 0; i < ts.size(); i++) {
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
/* Ordering alone is too weak to pin this down: clamping a wrong
* absolute position to "just after the last one" restores the
* ordering while leaving the positions wrong, and every forward
* lurch is still accepted. The producer clock is exact, so the
* spacing must be exact too. */
if (p > 100) { /* past the fit warm-up and its packetBurst fallback */
ASSERT_NEAR(ts[i] - last, 0.0025, 1e-5)
<< "sample spacing wrong at packet " << p;
}
last = ts[i];
}
}
}
// A PACKET burst has no per-element time at all. Elements span
// (lastPacket, thisPacket] — backwards from arrival, because the samples were
// acquired before the packet landed. Forward extrapolation would let a jittered
// packet overlap the next one and break ring monotonicity.
TEST(FrameDecoder, PacketBurstDropsTheFirstFrameThenSpansBackwards) {
FrameDecoder dec;
dec.setSignals({burst("Raw", kTimePacket, 0.0, 5, kNoTimeSignal)});
FrameBuilder fb1;
fb1.addSignal({1.0, 2.0, 3.0, 4.0, 5.0});
const FrameView& f1 = fb1.build(0, 10.0);
dec.beginFrame(f1);
std::vector<double> ts;
EXPECT_FALSE(dec.timestamps(f1, 0, ts))
<< "the first packet has no previous arrival to span from";
FrameBuilder fb2;
fb2.addSignal({6.0, 7.0, 8.0, 9.0, 10.0});
const FrameView& f2 = fb2.build(0, 10.05);
dec.beginFrame(f2);
ASSERT_TRUE(dec.timestamps(f2, 0, ts));
ASSERT_EQ(ts.size(), 5u);
EXPECT_GT(ts[0], 10.0);
EXPECT_NEAR(ts[4], 10.05, 1e-12);
EXPECT_NEAR(ts[1] - ts[0], 0.01, 1e-12);
}
TEST(FrameDecoder, PacketBurstStaysMonotonicUnderJitteredArrivals) {
FrameDecoder dec;
dec.setSignals({burst("Raw", kTimePacket, 0.0, 8, kNoTimeSignal)});
const double jitter[] = {0.0, 0.004, -0.003, 0.006, -0.002, 0.0, 0.005, -0.004};
std::vector<double> all;
for (int p = 0; p < 8; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(8, 1.0));
const FrameView& f = fb.build(0, 20.0 + p * 0.05 + jitter[p]);
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) {
all.insert(all.end(), ts.begin(), ts.end());
}
}
ASSERT_GT(all.size(), 8u);
for (size_t i = 1; i < all.size(); i++) {
EXPECT_GT(all[i], all[i - 1]) << "packets overlapped at " << i;
}
}
TEST(FrameDecoder, ResetForgetsPerSignalHistory) {
FrameDecoder dec;
dec.setSignals({burst("Raw", kTimePacket, 0.0, 4, kNoTimeSignal)});
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
const FrameView& f = fb.build(0, 5.0);
dec.beginFrame(f);
std::vector<double> ts;
EXPECT_FALSE(dec.timestamps(f, 0, ts));
const FrameView& f2 = fb.build(0, 5.1);
dec.beginFrame(f2);
EXPECT_TRUE(dec.timestamps(f2, 0, ts));
dec.reset();
const FrameView& f3 = fb.build(0, 5.2);
dec.beginFrame(f3);
EXPECT_FALSE(dec.timestamps(f3, 0, ts))
<< "after reset the next packet is again the first one";
}
- Step 3: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j
Expected: FAIL — FrameDecoder.h: No such file or directory.
- Step 4: Write the header
Create Client/udpscope/FrameDecoder.h:
/**
* @file FrameDecoder.h
* @brief Per-element timestamp reconstruction for UDPS frames.
*
* The C client's udps_frame_element_time() is explicitly an arrival-anchored
* estimate. It is not sufficient: the kernel frequently delivers several queued
* datagrams in one burst, so two packets are processed microseconds apart even
* though each represents ~10 ms of signal, and arrival-time interpolation then
* crams a packet's samples into that tiny gap — the trace renders as a sawtooth.
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
* solves it; these are the same rules, computed from udps_frame_t's own fields
* so the scope and StreamHub agree on the same stream.
*/
#pragma once
#include "TimeBase.h"
#include "Types.h"
#include <vector>
namespace udpscope {
class FrameDecoder {
public:
/** Installs the signal table. Clears all per-signal timing history. */
void setSignals(const std::vector<SignalMeta>& signals);
const std::vector<SignalMeta>& signals() const { return signals_; }
/** Call once per frame, before any timestamps() call for that frame. */
void beginFrame(const FrameView& f);
/**
* @brief Timestamps for every value of signal @p idx in this frame.
* @return false when the signal produced nothing usable — an empty slot, or
* the first PACKET burst after connect, which has no previous
* arrival to span from and would otherwise poison the ring with
* wrongly spaced timestamps.
*/
bool timestamps(const FrameView& f, uint32_t idx, std::vector<double>& tsOut);
/** Forgets all timing history; call on reconnect. */
void reset();
private:
bool packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
std::vector<double>& tsOut);
struct SigState {
ClockOffset offset;
double lastPacketWall = 0.0;
bool lastPacketValid = false;
/* Raw ticks, not seconds. HrtRateFit::toSeconds() divides an absolute
* tick count (~1e11 on a producer that has been up a while) by a rate
* refitted every packet, so its result carries the fit's few-parts-in-
* 1e4 wobble multiplied by the whole elapsed epoch — tens of ms of
* jitter on a value whose consecutive difference is a few ms.
* Differencing two toSeconds() results measures the wobble, not the
* interval. Difference the ticks and divide once instead. */
uint64_t lastAccHrt = 0u;
/* Producer seconds since this signal's first usable packet, built by
* SUMMING short tick deltas -- never recomputed from an absolute tick
* count. */
double accProdSec = 0.0;
bool lastAccValid = false;
uint32_t prevAccCount = 0;
double lastEmittedEnd = 0.0;
uint32_t lastCounter = 0u;
bool lastEmittedValid = false;
};
std::vector<SignalMeta> signals_;
std::vector<SigState> state_;
HrtRateFit hrtFit_;
};
} /* namespace udpscope */
- Step 5: Write the implementation
Create Client/udpscope/FrameDecoder.cpp:
#include "FrameDecoder.h"
namespace udpscope {
/** Fallback cycle period before the first inter-packet gap is known. */
static constexpr double kDefaultDt = 1.0e-3;
/**
* How far a chained burst prediction may sit from where arrival time says it
* should be before the chain is abandoned. A backstop only: the counter
* normally accounts for loss exactly. Same value and reasoning as
* ClockOffset::kRecalibThresholdS.
*/
static constexpr double kBurstResyncThresholdS = 0.5;
/** Narrowest a burst may be drawn, as a fraction of nominal, while a leading
* timeline is pulled back. Only a floor: the squeeze is normally proportional
* to the excess and removes it in one burst. See the sole use site. */
static constexpr double kMinBleedFactor = 0.05;
void FrameDecoder::setSignals(const std::vector<SignalMeta>& signals) {
signals_ = signals;
state_.assign(signals_.size(), SigState{});
hrtFit_.reset();
}
void FrameDecoder::reset() {
state_.assign(signals_.size(), SigState{});
hrtFit_.reset();
}
void FrameDecoder::beginFrame(const FrameView& f) {
if (f.hrt != 0u) { hrtFit_.add(f.hrt, f.recvTime); }
}
bool FrameDecoder::packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
std::vector<double>& tsOut) {
SigState& st = state_[idx];
if (!st.lastPacketValid || wallNow <= st.lastPacketWall) {
/* No previous arrival to span from, or time went backwards. Remember
* this one and drop the samples rather than store them at made-up
* spacing. */
st.lastPacketWall = wallNow;
st.lastPacketValid = true;
return false;
}
const double dt = (wallNow - st.lastPacketWall) / static_cast<double>(nElems);
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = st.lastPacketWall + static_cast<double>(e + 1u) * dt;
}
st.lastPacketWall = wallNow;
return true;
}
bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
std::vector<double>& tsOut) {
tsOut.clear();
if (idx >= signals_.size() || idx >= f.numSignals ||
f.counts == nullptr || f.values == nullptr) {
return false;
}
const SignalMeta& d = signals_[idx];
const uint32_t nElems = f.counts[idx];
if (nElems == 0u) { return false; }
const double wallNow = f.recvTime;
SigState& st = state_[idx];
/* A repeated counter is a duplicated datagram — the same update arriving
* twice because the host joined the multicast group on two interfaces, say.
* The C client de-duplicates FRAGMENTS only, so an unfragmented update
* reaches us intact both times; emitting it again would double the values
* and advance the timeline by a burst that never existed. Counter zero is
* excluded because a producer that never sets one leaves it there. */
if (st.lastEmittedValid && f.counter != 0u && f.counter == st.lastCounter) {
return false;
}
/* hasTimeSignal() bounds the index against the FRAME's signal count, but the
* time signal's type code is read from our own table, whose size is
* independent — a frame carrying more signals than the installed table
* (briefly possible after a CONFIG change) would otherwise read past it. */
const bool hasTimeSig = d.hasTimeSignal(f.numSignals) &&
d.timeSignalIdx < signals_.size();
const uint32_t tIdx = hasTimeSig ? d.timeSignalIdx : 0u;
const double tScale = hasTimeSig
? TimeSignalScale(signals_[tIdx].typeCode)
: 1.0e-6;
/* Rule 1: one stamp per element, straight from the time signal. */
if (d.timeMode == kTimeFullArray && hasTimeSig &&
f.counts[tIdx] >= nElems && f.values[tIdx] != nullptr) {
const double* tv = f.values[tIdx];
const double t0 = tv[0] * tScale;
(void) st.offset.map(t0, wallNow);
const double base = st.offset.offset();
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = base + tv[e] * tScale;
}
return true;
}
/* Rule 2: anchor from the time signal, spread by the sampling rate. */
if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) &&
hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) {
const double anchor = st.offset.map(f.values[tIdx][0] * tScale, wallNow);
const double dt = (d.samplingRate > 0.0) ? (1.0 / d.samplingRate) : 0.0;
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = (d.timeMode == kTimeFirstSample)
? (anchor + static_cast<double>(e) * dt)
: (anchor - static_cast<double>(nElems - 1u - e) * dt);
}
return true;
}
/* Rule 3: accumulated scalar.
*
* AMENDED after Task 4 review. The version below originally sent EVERY
* accumulated scalar through the hrt fit, falling back to packetBurst until
* the fit was ready. That cannot work when a declared samplingRate is
* present: HrtRateFit needs 32 packets, bursty delivery can begin before
* that, and packetBurst then crams a 10 ms burst into a 50 us arrival gap —
* exactly the sawtooth this rule exists to prevent. Worse, HrtRateFit fits
* hrt against ARRIVAL time, so a burst episode corrupts the very rate the
* fallback is waiting on.
*
* With a declared rate none of that is needed: the intra-packet step is
* exact and bursts are contiguous, so the next burst chains onto the end of
* the previous one. The one thing a bare chain gets wrong is LOSS — it
* closes the hole a dropped datagram left, dating every later sample early
* for the rest of the run — and the wire already says exactly how much is
* missing: FrameView::counter increments once per update, so a gap of g
* means g-1 lost packets. Reinstating that duration needs no estimate and
* no threshold. The arrival-anchor comparison is only a BACKSTOP for what
* the counter cannot express (producer restart, counter stuck at zero, a
* declared rate that is simply wrong), and it must never move time
* backwards. The hrt path below remains for samplingRate == 0. */
/* A signal that has already burst stays on this rule even when a later
* packet carries ONE sample: Accumulate mode flushes on a timer, so a short
* cycle legitimately yields one. Letting it fall to rule 5 would date it
* from arrival while its neighbours are chained, and would leave lastCounter
* behind so the next real burst read the skip as a lost datagram and
* reinstated a hole that never existed. A signal that has never burst is a
* genuine scalar and is left to rule 5. */
if (d.numElements() == 1u && (nElems > 1u || st.lastEmittedValid)) {
const double dtDeclared = (d.samplingRate > 0.0) ? (1.0 / d.samplingRate) : 0.0;
if (d.samplingRate > 0.0) {
const double arrivalAnchor =
wallNow - static_cast<double>(nElems - 1u) * dtDeclared;
double base = arrivalAnchor;
double step = dtDeclared;
if (st.lastEmittedValid) {
/* Unsigned subtraction wraps, so this is right across the
* counter's own 2^32 rollover. A producer restart or reordered
* datagram makes the wrapped gap enormous, and that is NOT
* special-cased: an absurd gap yields an absurd prediction,
* which the arrival backstop rejects on its own. Clamping the
* gap first would decide the same question earlier, by a second
* rule no stream can distinguish from this one. */
const uint32_t gap = f.counter - st.lastCounter;
const double lost = (gap > 1u)
? static_cast<double>(gap - 1u) *
static_cast<double>(st.prevAccCount)
: 0.0;
const double predicted = st.lastEmittedEnd + dtDeclared * (1.0 + lost);
if (std::fabs(predicted - arrivalAnchor) <= kBurstResyncThresholdS) {
base = predicted;
}
if (base <= st.lastEmittedEnd) {
/* Re-anchoring would step backwards, which the ring, the
* trigger and the exporter all forbid. But simply rejecting
* the correction makes the backstop ONE-DIRECTIONAL:
* `predicted` is never below lastEmittedEnd + dt, so a
* timeline running FAST — certain over a long session, two
* hosts' crystals differ by tens of ppm — would drift ahead
* without bound. Correct without stepping back instead. */
if (wallNow > st.lastEmittedEnd) {
/* Arrival is still ahead of us: start immediately after
* the last burst and spread this one out to arrival.
* One packet is drawn narrow; the timeline is in step. */
step = (wallNow - st.lastEmittedEnd) /
static_cast<double>(nElems);
base = st.lastEmittedEnd + step;
} else {
/* We have run PAST arrival, so no burst can end on
* arrival without starting before it. Squeeze this one
* by exactly the excess instead: its end lands one
* nominal width ahead of arrival -- the closest a
* forward-only timeline can legally get -- and the
* excess settles at (nominal width - true period),
* microseconds for a ppm-scale crystal mismatch.
*
* The floor keeps the step positive when the excess
* exceeds a whole burst (a declared rate wrong by a
* factor, not by ppm). It only slows recovery: each
* burst then advances by almost nothing while arrival
* keeps advancing, so the excess still reaches zero. */
const double nominal = static_cast<double>(nElems) * dtDeclared;
const double excess = st.lastEmittedEnd - wallNow;
double factor = 1.0 - excess / nominal;
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
step = dtDeclared * factor;
base = st.lastEmittedEnd + step;
}
}
}
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = base + static_cast<double>(e) * step;
}
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastCounter = f.counter;
st.prevAccCount = nElems;
st.lastEmittedValid = true;
return true;
}
/* No declared rate: need hrt-derived dt. */
if (!hrtFit_.ready() || f.hrt == 0u) {
return packetBurst(idx, nElems, wallNow, tsOut);
}
const double rate = hrtFit_.ticksPerSecond();
/* Integrate short tick DELTAS. Never convert an absolute tick count, and
* never subtract two such conversions.
*
* hrt counts from the producer's boot, so it is already ~1e11 ticks when
* the scope attaches, while the fit is re-estimated on every packet and
* wobbles by a few parts in 1e4. Any absolute hrt/rate therefore carries
* that relative wobble multiplied by the whole elapsed epoch — tens of
* milliseconds, moving in either direction from one packet to the next.
* As a burst's position that is not merely imprecise, it is
* NON-MONOTONIC: on a 2 h stream with ordinary scheduling jitter a few
* percent of samples land before their own predecessor.
*
* A delta spans one packet, so its share of the wobble is microseconds,
* and summing deltas keeps it there. ClockOffset then latches the
* arbitrary epoch that leaves behind, exactly as it would have latched
* the producer's boot epoch. */
double elapsed = 0.0;
if (st.lastAccValid && f.hrt > st.lastAccHrt) {
elapsed = static_cast<double>(f.hrt - st.lastAccHrt) / rate;
}
st.accProdSec += elapsed;
double base = st.offset.map(st.accProdSec, wallNow);
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
const double hrtDt = (elapsed > 0.0 && st.prevAccCount > 0u)
? (elapsed / static_cast<double>(st.prevAccCount))
: kDefaultDt;
/* ClockOffset recalibrates once true drift passes its threshold, and a
* recalibration can land behind where this signal already is.
* Downstream requires increasing stamps, so step forward minimally. */
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
base = st.lastEmittedEnd + hrtDt;
}
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = base + static_cast<double>(e) * hrtDt;
}
st.lastAccHrt = f.hrt;
st.lastAccValid = true;
st.prevAccCount = nElems;
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastEmittedValid = true;
return true;
}
/* Rule 4: PACKET burst with no time reference at all. */
if (nElems > 1u) {
return packetBurst(idx, nElems, wallNow, tsOut);
}
/* Rule 5: plain scalar. */
tsOut.assign(1, wallNow);
return true;
}
} /* namespace udpscope */
- Step 6: Register the source with CMake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
)
- Step 7: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'
Expected: PASS, 15 tests.
If AccumulatedScalarSurvivesBurstyDelivery fails, do NOT reach for the hrt fit: with a declared samplingRate rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and — since HrtRateFit regresses hrt against ARRIVAL time — is itself corrupted by the very bursts it would be asked to survive. Check instead that lastEmittedEnd, lastCounter, prevAccCount and lastEmittedValid are updated on every emitted burst.
Note for AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared: its arrivals carry zero-mean jitter on purpose. Under UNIFORM arrivals the hrt path and packetBurst return the same number by construction (the fit expresses hrt in arrival-clock seconds), so the test could not tell which branch answered. Its producer period is 25 ms, not 10 ms, for the same reason: at 10 ms the expected 1 ms answer equals kDefaultDt, so a decoder that derived nothing would pass.
If that test returns exactly kDefaultDt, or a value that wanders between runs of different length, the cause is almost certainly a reintroduced hrtFit_.toSeconds(a) - hrtFit_.toSeconds(b). toSeconds() divides an ABSOLUTE tick count by a rate refitted on every packet; a producer that has been up for a day is at ~1e11 ticks, so the fit's few-parts-in-1e4 wobble becomes tens of milliseconds of jitter on the result — larger than the interval being measured. Difference the raw ticks and divide once by ticksPerSecond().
If AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever fails with a lead that grows without bound, the proportional squeeze is not firing. Note that the "spread out to arrival" compression is unreachable in this case by construction: a LEADING timeline has lastEmittedEnd > wallNow, so there is no room to spread into. That branch handles only a bad prediction while arrival is still ahead; the leading case needs the kMinBleedFactor branch below it. Get the SIGN of the test's arrival spacing right — the producer must be FAST (arrivals closer together than the declared period, e.g. 0.0099 s for a 10 ms nominal burst). A slow producer makes the chain LAG, which the one-directional backstop already handles, so the test would pass with the squeeze deleted.
If AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot fails, the hrt path has been rewritten to position bursts from an ABSOLUTE tick conversion. Note that this test asserts spacing as well as order: the base <= lastEmittedEnd guard alone restores order while leaving positions wrong, so an order-only assertion would pass against a broken decoder.
- Step 8: Commit
git add Client/udpscope/FrameDecoder.h Client/udpscope/FrameDecoder.cpp \
Client/udpscope/Types.h Client/udpscope/tests/FrameDecoderTest.cpp \
Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): per-element timestamp reconstruction from UDPS frames"
Task 5: Trigger FSM
The trigger runs in the receiver thread, fed the same reconstructed
(t, v) arrays that go into the ring. It never stores samples itself — it
only decides when a capture window opened and closed. The GUI later reads
[tTrig - preSec, tTrig + postSec] out of the ring.
Files:
- Create:
Client/udpscope/Trigger.h - Create:
Client/udpscope/Trigger.cpp - Modify:
Client/udpscope/CMakeLists.txt(addTrigger.cpptoCORE_SOURCES) - Test:
Client/udpscope/tests/TriggerTest.cpp
Interfaces:
- Consumes: nothing from earlier tasks (pure logic over
doublearrays). - Produces:
enum class Edge { Rising, Falling, Both };
enum class TrigMode { Normal, Single };
enum class TrigState{ Idle, Armed, Collecting, Held };
struct TrigConfig {
std::string signalName;
Edge edge = Edge::Rising;
double threshold = 0.0;
double hysteresis = 0.0;
double windowSec = 0.1;
double prePercent = 20.0;
TrigMode mode = TrigMode::Normal;
double preSec() const;
double postSec() const;
};
class Trigger {
public:
void setConfig(const TrigConfig& c);
const TrigConfig& config() const;
void arm();
void disarm();
void rearm();
void feed(const double* t, const double* v, size_t n, double ringOldestTime);
TrigState state() const;
double fillFraction() const;
double trigTime() const;
bool captureReady() const;
void captureTaken();
static constexpr double kHarvestMarginSec = 0.05;
};
Why the harvest margin exists: the GUI reads the capture out of the ring
one repaint tick after the receiver declares it complete. Without a margin
the last samples of the window can be overwritten before they are read. This
is exactly the bug that was found and fixed in the Go hub
(Common/Client/go/wshub/ringbuf.go, captureLagSec) — do not remove it.
- Step 1: Write the failing tests
Create Client/udpscope/tests/TriggerTest.cpp:
#include "Trigger.h"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
using namespace udpscope;
namespace {
// Builds a Trigger armed on a rising edge through 0.5 with the given window.
Trigger makeTrigger(Edge e, double thr, double hyst,
double windowSec, double prePercent,
TrigMode mode = TrigMode::Normal) {
TrigConfig c;
c.signalName = "sig";
c.edge = e;
c.threshold = thr;
c.hysteresis = hyst;
c.windowSec = windowSec;
c.prePercent = prePercent;
c.mode = mode;
Trigger tr;
tr.setConfig(c);
return tr;
}
// Feeds one sample at a time so the FSM sees realistic packet granularity.
void feedOne(Trigger& tr, double t, double v, double oldest) {
tr.feed(&t, &v, 1, oldest);
}
} // namespace
TEST(TriggerConfig, SplitsTheWindowByThePrePercentage) {
TrigConfig c;
c.windowSec = 0.2;
c.prePercent = 25.0;
EXPECT_DOUBLE_EQ(c.preSec(), 0.05);
EXPECT_DOUBLE_EQ(c.postSec(), 0.15);
}
TEST(Trigger, StartsIdleAndOnlyArmsWhenAsked) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
EXPECT_EQ(tr.state(), TrigState::Idle);
// An unarmed trigger ignores a crossing entirely.
feedOne(tr, 1.0, 0.0, 0.0);
feedOne(tr, 1.001, 1.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Idle);
EXPECT_FALSE(tr.captureReady());
}
// The pre-window has to already be in the ring when the edge lands, or the
// capture has nothing to back-fill from. Arming reports Armed but the FSM
// refuses to accept an edge until the ring reaches back far enough.
TEST(Trigger, RefusesToFireBeforeThePreWindowIsBuffered) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05
tr.arm();
ASSERT_EQ(tr.state(), TrigState::Armed);
// Ring only reaches back to t = 0.98, i.e. 0.02 s of history at t = 1.0.
feedOne(tr, 1.000, 0.0, 0.98);
feedOne(tr, 1.001, 1.0, 0.98);
EXPECT_EQ(tr.state(), TrigState::Armed) << "fired without a full pre-window";
EXPECT_LT(tr.fillFraction(), 1.0);
// Now the ring reaches back 0.06 s and the same edge is accepted.
feedOne(tr, 1.100, 0.0, 1.04);
feedOne(tr, 1.101, 1.0, 1.04);
EXPECT_EQ(tr.state(), TrigState::Collecting);
}
TEST(Trigger, FillFractionReportsPreWindowProgress) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05
tr.arm();
feedOne(tr, 1.0, 0.0, 0.975); // 0.025 s of 0.05 s
EXPECT_NEAR(tr.fillFraction(), 0.5, 1e-9);
feedOne(tr, 1.0, 0.0, 0.90); // more than enough
EXPECT_DOUBLE_EQ(tr.fillFraction(), 1.0);
}
// The crossing almost never lands exactly on a sample. Interpolating gives a
// stable trigger point instead of one that jitters by a sample period.
TEST(Trigger, InterpolatesTheCrossingBetweenSamples) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
// tTrig = 1.000 + (0.5 - 0.0)/(1.0 - 0.0) * 0.010 = 1.005
ASSERT_EQ(tr.state(), TrigState::Collecting);
EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12);
}
TEST(Trigger, FallingEdgeFiresOnTheDownwardCrossing) {
Trigger tr = makeTrigger(Edge::Falling, 0.5, 0.0, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 1.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Armed);
feedOne(tr, 1.010, 0.0, 0.0);
ASSERT_EQ(tr.state(), TrigState::Collecting);
EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12);
}
TEST(Trigger, RisingEdgeIgnoresADownwardCrossing) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 1.0, 0.0);
feedOne(tr, 1.010, 0.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Armed);
}
TEST(Trigger, BothEdgesFireOnWhicheverComesFirst) {
Trigger tr = makeTrigger(Edge::Both, 0.5, 0.0, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 1.0, 0.0);
feedOne(tr, 1.010, 0.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Collecting);
}
// A noisy signal riding on the threshold produces a burst of crossings. With
// hysteresis the signal must first retreat past threshold - hysteresis before
// another rising edge counts, so one physical event yields one trigger.
TEST(Trigger, HysteresisSuppressesARecrossFromNoise) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.2, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
ASSERT_EQ(tr.state(), TrigState::Collecting);
const double first = tr.trigTime();
// Close the capture and re-arm, then wiggle just below threshold: 0.45 is
// under 0.5 but has not retreated past the 0.3 arm level.
feedOne(tr, 1.200, 0.0, 0.0); // past 1.005 + 0.08 + 0.05
ASSERT_TRUE(tr.captureReady());
tr.captureTaken();
ASSERT_EQ(tr.state(), TrigState::Armed);
feedOne(tr, 1.300, 0.45, 0.0);
feedOne(tr, 1.310, 0.60, 0.0);
EXPECT_EQ(tr.state(), TrigState::Armed) << "re-armed inside the hysteresis band";
// A genuine retreat below 0.3 re-arms the detector.
feedOne(tr, 1.400, 0.10, 0.0);
feedOne(tr, 1.410, 0.60, 0.0);
EXPECT_EQ(tr.state(), TrigState::Collecting);
EXPECT_GT(tr.trigTime(), first);
}
// Collecting must run past the end of the post-window by the harvest margin,
// because the GUI reads the ring a tick after the receiver says "done".
TEST(Trigger, CollectingEndsAPostWindowPlusMarginAfterTheEdge) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); // post = 0.08
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0); // tTrig = 1.005
ASSERT_EQ(tr.state(), TrigState::Collecting);
const double end = 1.005 + 0.08 + Trigger::kHarvestMarginSec;
feedOne(tr, end - 1e-3, 1.0, 0.0);
EXPECT_FALSE(tr.captureReady()) << "harvested before the margin elapsed";
feedOne(tr, end + 1e-3, 1.0, 0.0);
EXPECT_TRUE(tr.captureReady());
}
TEST(Trigger, NormalModeRearmsAfterTheCaptureIsTaken) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Normal);
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
feedOne(tr, 1.500, 1.0, 0.0);
ASSERT_TRUE(tr.captureReady());
tr.captureTaken();
EXPECT_EQ(tr.state(), TrigState::Armed);
EXPECT_FALSE(tr.captureReady());
}
TEST(Trigger, SingleModeHoldsAfterTheCaptureIsTaken) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Single);
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
feedOne(tr, 1.500, 1.0, 0.0);
ASSERT_TRUE(tr.captureReady());
tr.captureTaken();
EXPECT_EQ(tr.state(), TrigState::Held);
// A Held trigger ignores further edges until explicitly re-armed.
feedOne(tr, 2.000, 0.0, 0.0);
feedOne(tr, 2.010, 1.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Held);
tr.rearm();
EXPECT_EQ(tr.state(), TrigState::Armed);
}
TEST(Trigger, ChangingTheConfigAbandonsAnInFlightCapture) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
tr.arm();
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
ASSERT_EQ(tr.state(), TrigState::Collecting);
TrigConfig c = tr.config();
c.threshold = 0.9;
tr.setConfig(c);
EXPECT_EQ(tr.state(), TrigState::Idle) << "kept a capture cut to the old config";
}
TEST(Trigger, DisarmDropsBackToIdle) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
tr.arm();
tr.disarm();
EXPECT_EQ(tr.state(), TrigState::Idle);
feedOne(tr, 1.000, 0.0, 0.0);
feedOne(tr, 1.010, 1.0, 0.0);
EXPECT_EQ(tr.state(), TrigState::Idle);
}
// A single packet carries thousands of samples; the edge is somewhere inside
// it, and the same packet can also carry the whole post-window.
TEST(Trigger, FindsAnEdgeInTheMiddleOfALargeBlockAndCanCompleteInIt) {
Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.02, 25.0); // post = 0.015
tr.arm();
std::vector<double> t(1000), v(1000);
for (size_t i = 0; i < t.size(); ++i) {
t[i] = 1.0 + static_cast<double>(i) * 1e-4; // 10 kHz, 0.1 s span
v[i] = (i < 500) ? 0.0 : 1.0;
}
tr.feed(t.data(), v.data(), t.size(), 0.5);
// Crossing between i=499 (t=1.0499, v=0) and i=500 (t=1.05, v=1):
// tTrig = 1.0499 + 0.5 * 1e-4 = 1.04995
EXPECT_NEAR(tr.trigTime(), 1.04995, 1e-9);
EXPECT_TRUE(tr.captureReady()) << "block extends past the post-window and margin";
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Trigger.h: No such file or directory.
- Step 3: Write the header
Create Client/udpscope/Trigger.h:
/**
* @file Trigger.h
* @brief Client-side edge trigger FSM for UDPScope.
*
* The trigger observes one signal's reconstructed samples in the receiver
* thread and decides when a capture window opened and closed. It stores no
* samples: the GUI reads [trigTime() - preSec, trigTime() + postSec] out of
* the ring once captureReady() goes true.
*/
#ifndef UDPSCOPE_TRIGGER_H
#define UDPSCOPE_TRIGGER_H
#include <cstddef>
#include <string>
namespace udpscope {
enum class Edge { Rising, Falling, Both };
enum class TrigMode { Normal, Single };
enum class TrigState{ Idle, Armed, Collecting, Held };
struct TrigConfig {
std::string signalName;
Edge edge = Edge::Rising;
double threshold = 0.0;
double hysteresis = 0.0;
double windowSec = 0.1;
double prePercent = 20.0;
TrigMode mode = TrigMode::Normal;
double preSec() const { return windowSec * prePercent / 100.0; }
double postSec() const { return windowSec - preSec(); }
};
class Trigger {
public:
/**
* Extra time the FSM keeps Collecting past the end of the post-window.
*
* The GUI harvests the capture out of the ring on its next repaint, which
* is up to one frame after the receiver declares the window complete. The
* ring must therefore still hold the window's last samples then. The Go
* hub had this same bug without a margin (captureLagSec in
* Common/Client/go/wshub/ringbuf.go) and truncated every capture. Do not
* remove this.
*/
static constexpr double kHarvestMarginSec = 0.05;
void setConfig(const TrigConfig& c);
const TrigConfig& config() const { return cfg_; }
void arm(); /**< Idle -> Armed. No-op if already armed. */
void disarm(); /**< Any state -> Idle, abandoning an in-flight capture. */
void rearm(); /**< Held -> Armed, discarding the held capture. */
/**
* Feed one packet's worth of reconstructed samples.
*
* @param t per-sample times, strictly increasing
* @param v per-sample values of the trigger signal
* @param n number of samples
* @param ringOldestTime time of the oldest sample still in the ring; used
* to gate arming until the pre-window is buffered
*/
void feed(const double* t, const double* v, size_t n, double ringOldestTime);
TrigState state() const { return state_; }
/** Fraction of the pre-window currently held by the ring, clamped to 1. */
double fillFraction() const { return fill_; }
double trigTime() const { return trigTime_; }
bool captureReady() const { return ready_; }
/** Called by the GUI once it has copied the capture out of the ring. */
void captureTaken();
private:
void reset();
bool crosses(double v0, double v1) const;
TrigConfig cfg_;
TrigState state_ = TrigState::Idle;
double fill_ = 0.0;
double trigTime_ = 0.0;
double endTime_ = 0.0;
bool ready_ = false;
bool havePrev_ = false;
double prevT_ = 0.0;
double prevV_ = 0.0;
/** Hysteresis gate: false until the signal has retreated past the arm level. */
bool gateOpen_ = true;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_TRIGGER_H */
- Step 4: Write the implementation
Create Client/udpscope/Trigger.cpp:
#include "Trigger.h"
#include <algorithm>
namespace udpscope {
void Trigger::reset() {
state_ = TrigState::Idle;
fill_ = 0.0;
trigTime_ = 0.0;
endTime_ = 0.0;
ready_ = false;
havePrev_ = false;
gateOpen_ = true;
}
void Trigger::setConfig(const TrigConfig& c) {
cfg_ = c;
/* A capture cut to the old threshold/window would be misleading once the
config changes, so drop it rather than finish it. */
reset();
}
void Trigger::arm() {
if (state_ == TrigState::Idle) {
state_ = TrigState::Armed;
ready_ = false;
havePrev_ = false;
gateOpen_ = true;
}
}
void Trigger::disarm() { reset(); }
void Trigger::rearm() {
reset();
arm();
}
void Trigger::captureTaken() {
if (!ready_) {
return;
}
ready_ = false;
if (cfg_.mode == TrigMode::Single) {
state_ = TrigState::Held;
} else {
state_ = TrigState::Armed;
havePrev_ = false;
/* Force a retreat past the arm level before the next edge counts, so
ringing on the tail of this capture cannot immediately re-trigger. */
gateOpen_ = (cfg_.hysteresis <= 0.0);
}
}
bool Trigger::crosses(double v0, double v1) const {
const double thr = cfg_.threshold;
const bool up = (v0 < thr) && (v1 >= thr);
const bool down = (v0 > thr) && (v1 <= thr);
switch (cfg_.edge) {
case Edge::Rising: return up;
case Edge::Falling: return down;
case Edge::Both: return up || down;
}
return false;
}
void Trigger::feed(const double* t, const double* v, size_t n, double ringOldestTime) {
if (n == 0u || state_ == TrigState::Idle || state_ == TrigState::Held) {
return;
}
for (size_t i = 0u; i < n; ++i) {
const double ti = t[i];
const double vi = v[i];
if (state_ == TrigState::Collecting) {
if (ti >= endTime_) {
ready_ = true;
return; /* the rest of the block belongs to the next capture */
}
continue;
}
/* Armed. The pre-window must already be in the ring or the capture
has nothing to back-fill from. */
const double pre = cfg_.preSec();
const double have = ti - ringOldestTime;
fill_ = (pre > 0.0) ? std::min(1.0, std::max(0.0, have / pre)) : 1.0;
if (!havePrev_) {
havePrev_ = true;
prevT_ = ti;
prevV_ = vi;
continue;
}
/* Hysteresis: after a fire the signal must retreat past the arm level
before another edge of the same polarity is accepted. */
if (!gateOpen_ && cfg_.hysteresis > 0.0) {
const double armLevel = (cfg_.edge == Edge::Falling)
? cfg_.threshold + cfg_.hysteresis
: cfg_.threshold - cfg_.hysteresis;
const bool retreated = (cfg_.edge == Edge::Falling) ? (vi >= armLevel)
: (vi <= armLevel);
if (retreated) {
gateOpen_ = true;
}
prevT_ = ti;
prevV_ = vi;
continue;
}
if (fill_ >= 1.0 && crosses(prevV_, vi)) {
const double dv = vi - prevV_;
const double frac = (dv != 0.0) ? (cfg_.threshold - prevV_) / dv : 0.0;
trigTime_ = prevT_ + frac * (ti - prevT_);
endTime_ = trigTime_ + cfg_.postSec() + kHarvestMarginSec;
state_ = TrigState::Collecting;
gateOpen_ = (cfg_.hysteresis <= 0.0);
continue; /* re-enters the Collecting branch on the next sample */
}
prevT_ = ti;
prevV_ = vi;
}
}
} /* namespace udpscope */
Note on Edge::Both with hysteresis: the arm level is derived from the
configured edge, and Both uses the rising form. That is a deliberate
simplification — a symmetric band would need two gates and no bench scope
exposes that. It is documented in Docs/UDPScope.md (Task 16).
- Step 5: Register the source with CMake
In Client/udpscope/CMakeLists.txt, extend CORE_SOURCES:
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
)
- Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Trigger*'
Expected: PASS, 15 tests.
- Step 7: Run the whole suite to check nothing regressed
cd Client/udpscope && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–5.
- Step 8: Commit
git add Client/udpscope/Trigger.h Client/udpscope/Trigger.cpp \
Client/udpscope/tests/TriggerTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): edge trigger FSM with fill gate and harvest margin"
Task 6: SignalStore — the one lock between the two threads
SignalStore owns every ring, the latest vector profiles, and the most
recent completed capture. The receiver thread writes; the GUI thread reads;
one std::mutex guards all of it. Nothing else in the program is shared
between the threads.
Files:
- Create:
Client/udpscope/SignalStore.h - Create:
Client/udpscope/SignalStore.cpp - Modify:
Client/udpscope/CMakeLists.txt(addSignalStore.cpptoCORE_SOURCES) - Test:
Client/udpscope/tests/SignalStoreTest.cpp
Interfaces:
- Consumes:
SignalMeta,SeriesfromTypes.h(Task 1/4);StreamHubClient::SignalBufferfrom../streamhub/SignalBuffer.h(already on the include path from Task 1). - Produces:
struct Profile { double time = 0.0; std::vector<double> x, v; };
struct Capture {
uint64_t seq = 0;
double trigTime = 0.0, t0 = 0.0, t1 = 0.0;
std::vector<std::string> names;
std::vector<Series> series;
};
class SignalStore {
public:
static constexpr double kRingMargin = 4.0;
static constexpr size_t kMinRingPoints = 4096u;
static constexpr size_t kMaxRingPoints = 4000000u;
static constexpr size_t kTotalPointBudget = 16000000u;
void setSignals(const std::vector<SignalMeta>& metas);
std::vector<SignalMeta> signals() const;
uint64_t generation() const;
void push(const std::string& name, const double* t, const double* v, size_t n);
void pushProfile(const std::string& name, double time, const double* v, size_t n);
size_t readLast(const std::string& name, size_t n, Series& out) const;
size_t readRange(const std::string& name, double t0, double t1, Series& out) const;
bool readProfile(const std::string& name, Profile& out) const;
bool span(const std::string& name, double& oldest, double& newest) const;
double rate(const std::string& name) const;
size_t capacity(const std::string& name) const;
void setWindowSec(double windowSec);
double windowSec() const;
void maintain();
void publishCapture(Capture&& c);
uint64_t captureSeq() const;
bool readCapture(Capture& out) const;
};
Why kRingMargin is 4.0 and must stay explicit: the rings must hold
more than the trigger window itself. The pre-window has to be resident
before the edge arrives, the post-window accumulates after it, the
capture is harvested a repaint later (Trigger::kHarvestMarginSec), and the
rate estimate that sizes the ring lags a step behind a rate change. Four
window-lengths covers all four at a cost of a few tens of MB. Sizing the
rings to the window alone is precisely the bug that truncated every capture
in the Go hub. Do not tune this down without re-running the capture tests.
- Step 1: Write the failing tests
Create Client/udpscope/tests/SignalStoreTest.cpp:
#include "SignalStore.h"
#include <gtest/gtest.h>
#include <atomic>
#include <cmath>
#include <thread>
#include <vector>
using namespace udpscope;
namespace {
SignalMeta scalarMeta(const std::string& name) {
SignalMeta m;
m.name = name;
m.typeCode = 8; /* float32 */
m.numRows = 1;
m.numCols = 1;
return m;
}
// Pushes `n` samples at `rate` Hz starting at t0; returns the time just past
// the last sample so callers can chain blocks.
double pushBlock(SignalStore& s, const std::string& name,
double t0, double rate, size_t n) {
std::vector<double> t(n), v(n);
const double dt = 1.0 / rate;
for (size_t i = 0; i < n; ++i) {
t[i] = t0 + static_cast<double>(i) * dt;
v[i] = static_cast<double>(i);
}
s.push(name, t.data(), v.data(), n);
return t0 + static_cast<double>(n) * dt;
}
} // namespace
TEST(SignalStore, CreatesARingPerSignalAtTheMinimumCapacity) {
SignalStore s;
s.setSignals({scalarMeta("a"), scalarMeta("b")});
EXPECT_EQ(s.signals().size(), 2u);
EXPECT_EQ(s.capacity("a"), SignalStore::kMinRingPoints);
EXPECT_EQ(s.capacity("b"), SignalStore::kMinRingPoints);
}
TEST(SignalStore, PushAndReadLastRoundTrip) {
SignalStore s;
s.setSignals({scalarMeta("a")});
const double t[] = {1.0, 2.0, 3.0};
const double v[] = {10.0, 20.0, 30.0};
s.push("a", t, v, 3);
Series out;
ASSERT_EQ(s.readLast("a", 10, out), 3u);
EXPECT_DOUBLE_EQ(out.t[0], 1.0);
EXPECT_DOUBLE_EQ(out.v[2], 30.0);
}
TEST(SignalStore, ReadRangeClipsToTheRequestedInterval) {
SignalStore s;
s.setSignals({scalarMeta("a")});
pushBlock(s, "a", 0.0, 1000.0, 1000); // 0 .. 0.999 s
Series out;
ASSERT_EQ(s.readRange("a", 0.100, 0.200, out), 101u);
EXPECT_NEAR(out.t.front(), 0.100, 1e-9);
EXPECT_NEAR(out.t.back(), 0.200, 1e-9);
}
TEST(SignalStore, SpanReportsTheOldestAndNewestResidentSample) {
SignalStore s;
s.setSignals({scalarMeta("a")});
double oldest = 0.0, newest = 0.0;
EXPECT_FALSE(s.span("a", oldest, newest)) << "empty ring must report no span";
// Overfill the ring so the oldest samples are gone.
const size_t n = SignalStore::kMinRingPoints + 500u;
pushBlock(s, "a", 0.0, 1000.0, n);
ASSERT_TRUE(s.span("a", oldest, newest));
EXPECT_NEAR(oldest, 500.0 / 1000.0, 1e-9);
EXPECT_NEAR(newest, static_cast<double>(n - 1) / 1000.0, 1e-9);
}
TEST(SignalStore, RateEstimateTracksThePushCadence) {
SignalStore s;
s.setSignals({scalarMeta("a")});
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "a", t, 10000.0, 100); // 10 kHz in 10 ms blocks
}
EXPECT_NEAR(s.rate("a"), 10000.0, 200.0);
}
TEST(SignalStore, UnknownSignalsAreIgnoredRatherThanCreated) {
SignalStore s;
s.setSignals({scalarMeta("a")});
const double t = 1.0, v = 2.0;
s.push("ghost", &t, &v, 1);
Series out;
EXPECT_EQ(s.readLast("ghost", 10, out), 0u);
EXPECT_EQ(s.capacity("ghost"), 0u);
EXPECT_EQ(s.signals().size(), 1u) << "push must not invent a signal";
}
TEST(SignalStore, SetSignalsClearsPreviousDataAndBumpsTheGeneration) {
SignalStore s;
s.setSignals({scalarMeta("a")});
pushBlock(s, "a", 0.0, 1000.0, 100);
const uint64_t g0 = s.generation();
s.setSignals({scalarMeta("a"), scalarMeta("b")});
EXPECT_GT(s.generation(), g0);
Series out;
EXPECT_EQ(s.readLast("a", 10, out), 0u) << "stale samples survived a reconfigure";
}
// The whole point of the store: a 1 MSps signal with a 0.2 s trigger window
// needs 200 k points for the window itself and kRingMargin times that in the
// ring, or the capture is overwritten before the GUI can read it.
TEST(SignalStore, MaintainGrowsTheRingToTheWindowTimesTheMargin) {
SignalStore s;
s.setSignals({scalarMeta("a")});
s.setWindowSec(0.2);
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
}
s.maintain();
const size_t want = static_cast<size_t>(1.0e6 * 0.2 * SignalStore::kRingMargin);
EXPECT_NEAR(static_cast<double>(s.capacity("a")), static_cast<double>(want),
0.1 * static_cast<double>(want));
}
TEST(SignalStore, MaintainPreservesTheSamplesAlreadyBuffered) {
SignalStore s;
s.setSignals({scalarMeta("a")});
s.setWindowSec(0.2);
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
}
Series before;
const size_t n = s.readLast("a", 1000, before);
ASSERT_EQ(n, 1000u);
s.maintain();
Series after;
ASSERT_EQ(s.readLast("a", 1000, after), 1000u) << "resize threw the ring away";
for (size_t i = 0; i < n; ++i) {
ASSERT_DOUBLE_EQ(after.t[i], before.t[i]) << "sample " << i << " moved";
ASSERT_DOUBLE_EQ(after.v[i], before.v[i]);
}
}
TEST(SignalStore, MaintainShrinksTheRingWhenTheWindowShrinks) {
SignalStore s;
s.setSignals({scalarMeta("a")});
s.setWindowSec(1.0);
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
}
s.maintain();
const size_t big = s.capacity("a");
s.setWindowSec(0.01);
s.maintain();
EXPECT_LT(s.capacity("a"), big / 2u);
EXPECT_GE(s.capacity("a"), SignalStore::kMinRingPoints);
}
TEST(SignalStore, MaintainClampsToTheMinimumAndTheMaximum) {
SignalStore s;
s.setSignals({scalarMeta("slow")});
s.setWindowSec(1e-6);
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "slow", t, 10.0, 2);
}
s.maintain();
EXPECT_EQ(s.capacity("slow"), SignalStore::kMinRingPoints);
SignalStore fast;
fast.setSignals({scalarMeta("fast")});
fast.setWindowSec(3600.0);
t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(fast, "fast", t, 1.0e6, 1000);
}
fast.maintain();
EXPECT_EQ(fast.capacity("fast"), SignalStore::kMaxRingPoints);
}
// Rings are not allowed to sum past the global budget, however many signals
// the streamer publishes.
TEST(SignalStore, MaintainSharesTheGlobalBudgetBetweenSignals) {
SignalStore s;
std::vector<SignalMeta> metas;
for (int i = 0; i < 8; ++i) {
metas.push_back(scalarMeta("s" + std::to_string(i)));
}
s.setSignals(metas);
s.setWindowSec(10.0);
for (int i = 0; i < 8; ++i) {
double t = 0.0;
for (int b = 0; b < 50; ++b) {
t = pushBlock(s, "s" + std::to_string(i), t, 1.0e6, 1000);
}
}
s.maintain();
size_t total = 0u;
for (int i = 0; i < 8; ++i) {
total += s.capacity("s" + std::to_string(i));
}
EXPECT_LE(total, SignalStore::kTotalPointBudget);
EXPECT_GT(total, SignalStore::kTotalPointBudget / 2u) << "budget left unused";
}
// A resize on every maintain() would clear-and-refill 64 MB per call at 60 Hz.
TEST(SignalStore, MaintainIsAStableNoOpWhenNothingChanged) {
SignalStore s;
s.setSignals({scalarMeta("a")});
s.setWindowSec(0.2);
double t = 0.0;
for (int i = 0; i < 50; ++i) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
}
s.maintain();
const size_t cap = s.capacity("a");
for (int i = 0; i < 10; ++i) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
s.maintain();
}
EXPECT_EQ(s.capacity("a"), cap);
}
TEST(SignalStore, ProfileKeepsOnlyTheLatestSnapshot) {
SignalStore s;
SignalMeta m = scalarMeta("vec");
m.numCols = 4;
m.profileOverride = true;
s.setSignals({m});
const double a[] = {1.0, 2.0, 3.0, 4.0};
const double b[] = {5.0, 6.0, 7.0, 8.0};
s.pushProfile("vec", 1.0, a, 4);
s.pushProfile("vec", 2.0, b, 4);
Profile p;
ASSERT_TRUE(s.readProfile("vec", p));
EXPECT_DOUBLE_EQ(p.time, 2.0);
ASSERT_EQ(p.v.size(), 4u);
EXPECT_DOUBLE_EQ(p.v[0], 5.0);
EXPECT_DOUBLE_EQ(p.x[3], 3.0) << "x must be the element index";
}
TEST(SignalStore, CaptureIsPublishedAndReadBackWithARisingSequence) {
SignalStore s;
EXPECT_EQ(s.captureSeq(), 0u);
Capture none;
EXPECT_FALSE(s.readCapture(none));
Capture c;
c.trigTime = 5.0;
c.t0 = 4.9;
c.t1 = 5.1;
c.names.push_back("a");
c.series.emplace_back();
c.series[0].t = {4.9, 5.0, 5.1};
c.series[0].v = {0.0, 1.0, 0.0};
s.publishCapture(std::move(c));
EXPECT_EQ(s.captureSeq(), 1u);
Capture got;
ASSERT_TRUE(s.readCapture(got));
EXPECT_EQ(got.seq, 1u);
EXPECT_DOUBLE_EQ(got.trigTime, 5.0);
ASSERT_EQ(got.series.size(), 1u);
EXPECT_EQ(got.series[0].t.size(), 3u);
Capture c2;
c2.trigTime = 6.0;
s.publishCapture(std::move(c2));
EXPECT_EQ(s.captureSeq(), 2u);
}
// Not a proof of correctness, but it catches an unlocked member or a
// use-after-resize. Run the suite under -fsanitize=thread occasionally.
TEST(SignalStore, ConcurrentPushAndReadDoNotCrash) {
SignalStore s;
s.setSignals({scalarMeta("a")});
s.setWindowSec(0.05);
std::atomic<bool> stop(false);
std::thread writer([&] {
double t = 0.0;
while (!stop.load()) {
t = pushBlock(s, "a", t, 1.0e6, 1000);
s.maintain();
}
});
Series out;
for (int i = 0; i < 2000; ++i) {
s.readLast("a", 4096, out);
double o = 0.0, nw = 0.0;
(void)s.span("a", o, nw);
(void)s.rate("a");
}
stop.store(true);
writer.join();
/* Not just "it did not crash": the store must still be coherent after the
race. The window is 0.05 s at 1 MHz, so the ring spans at most
0.05 * kRingMargin seconds, and readLast was capped at 4096 points. */
double oldest = 0.0, newest = 0.0;
ASSERT_TRUE(s.span("a", oldest, newest));
EXPECT_GE(newest, oldest);
EXPECT_LE(newest - oldest, 0.05 * SignalStore::kRingMargin * 1.5);
EXPECT_LE(out.size(), 4096u);
EXPECT_EQ(out.t.size(), out.v.size());
for (size_t i = 1; i < out.size(); ++i) {
EXPECT_GE(out.t[i], out.t[i - 1]) << "timestamps went backwards at " << i;
}
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — SignalStore.h: No such file or directory.
- Step 3: Write the header
Create Client/udpscope/SignalStore.h:
/**
* @file SignalStore.h
* @brief The single mutex-guarded handoff between the receiver and GUI threads.
*
* Owns one ring per scalar signal, the latest snapshot per vector-profile
* signal, and the most recent completed capture. Every accessor takes the
* same lock; the reused StreamHubClient::SignalBuffer has none of its own
* despite what its comment claims.
*/
#ifndef UDPSCOPE_SIGNALSTORE_H
#define UDPSCOPE_SIGNALSTORE_H
#include "SignalBuffer.h"
#include "Types.h"
#include <cstdint>
#include <map>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
/** Latest snapshot of a true vector signal, plotted against element index. */
struct Profile {
double time = 0.0;
std::vector<double> x, v;
};
/** One completed trigger capture, copied out of the rings by the receiver. */
struct Capture {
uint64_t seq = 0u;
double trigTime = 0.0;
double t0 = 0.0;
double t1 = 0.0;
std::vector<std::string> names;
std::vector<Series> series;
};
class SignalStore {
public:
/**
* Ring length as a multiple of the trigger window.
*
* The window alone is not enough: the pre-window must be resident before
* the edge arrives, the post-window fills after it, the GUI harvests a
* repaint later (Trigger::kHarvestMarginSec), and the rate estimate that
* sizes the ring lags one step behind a rate change. Undersized rings are
* what truncated every capture in the Go hub. Do not reduce this without
* re-running the capture tests.
*/
static constexpr double kRingMargin = 4.0;
/** Floor, so a slow signal still shows a usable live trace. */
static constexpr size_t kMinRingPoints = 4096u;
/** Per-signal ceiling: 4 M points is 64 MB of (t,v) pairs. */
static constexpr size_t kMaxRingPoints = 4000000u;
/** Ceiling on the sum of all rings: 16 M points is 256 MB. */
static constexpr size_t kTotalPointBudget = 16000000u;
/** Replace the signal set. Clears all rings and bumps generation(). */
void setSignals(const std::vector<SignalMeta>& metas);
std::vector<SignalMeta> signals() const;
/** Increments on every setSignals(); the GUI uses it to drop stale state. */
uint64_t generation() const;
/** Append samples. Unknown names are dropped, never auto-created. */
void push(const std::string& name, const double* t, const double* v, size_t n);
void pushProfile(const std::string& name, double time, const double* v, size_t n);
size_t readLast(const std::string& name, size_t n, Series& out) const;
size_t readRange(const std::string& name, double t0, double t1, Series& out) const;
bool readProfile(const std::string& name, Profile& out) const;
/** @return false when the ring is empty. */
bool span(const std::string& name, double& oldest, double& newest) const;
/** Smoothed samples-per-second, independent of the ring's current length. */
double rate(const std::string& name) const;
size_t capacity(const std::string& name) const;
void setWindowSec(double windowSec);
double windowSec() const;
/** Re-size rings whose target has drifted. Call once per receiver poll. */
void maintain();
void publishCapture(Capture&& c);
uint64_t captureSeq() const;
bool readCapture(Capture& out) const;
private:
struct Entry {
StreamHubClient::SignalBuffer buf{kMinRingPoints};
double rate = 0.0;
double lastT = 0.0;
bool haveLast = false;
bool profile = false;
Profile snapshot;
};
/** Caller holds mu_. */
size_t targetCapacity(const Entry& e, size_t ringCount) const;
mutable std::mutex mu_;
std::vector<SignalMeta> metas_;
std::map<std::string, Entry> entries_;
double windowSec_ = 0.1;
uint64_t generation_ = 0u;
Capture capture_;
uint64_t captureSeq_ = 0u;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_SIGNALSTORE_H */
- Step 4: Write the implementation
Create Client/udpscope/SignalStore.cpp:
#include "SignalStore.h"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/** Smoothing factor for the per-signal rate estimate (one block per update). */
const double kRateAlpha = 0.1;
/** Relative drift that justifies paying for a resize. */
const double kResizeHysteresis = 0.25;
double oldestOf(const StreamHubClient::SignalBuffer& b) {
const size_t idx = (b.head + b.capacity - b.count) % b.capacity;
return b.t[idx];
}
double newestOf(const StreamHubClient::SignalBuffer& b) {
const size_t idx = (b.head + b.capacity - 1u) % b.capacity;
return b.t[idx];
}
} /* namespace */
void SignalStore::setSignals(const std::vector<SignalMeta>& metas) {
std::lock_guard<std::mutex> lk(mu_);
metas_ = metas;
entries_.clear();
for (size_t i = 0u; i < metas_.size(); ++i) {
Entry e;
e.profile = metas_[i].isVectorProfile();
entries_.emplace(metas_[i].name, std::move(e));
}
++generation_;
}
std::vector<SignalMeta> SignalStore::signals() const {
std::lock_guard<std::mutex> lk(mu_);
return metas_;
}
uint64_t SignalStore::generation() const {
std::lock_guard<std::mutex> lk(mu_);
return generation_;
}
void SignalStore::push(const std::string& name, const double* t, const double* v, size_t n) {
if (n == 0u) {
return;
}
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::iterator it = entries_.find(name);
if (it == entries_.end()) {
return;
}
Entry& e = it->second;
for (size_t i = 0u; i < n; ++i) {
e.buf.push(t[i], v[i]);
}
/* Rate from this block's span, so it survives a ring resize. */
const double last = t[n - 1u];
if (e.haveLast) {
const double dt = last - e.lastT;
if (dt > 0.0) {
const double inst = static_cast<double>(n) / dt;
e.rate = (e.rate > 0.0) ? (1.0 - kRateAlpha) * e.rate + kRateAlpha * inst
: inst;
}
}
e.lastT = last;
e.haveLast = true;
}
void SignalStore::pushProfile(const std::string& name, double time,
const double* v, size_t n) {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::iterator it = entries_.find(name);
if (it == entries_.end()) {
return;
}
Profile& p = it->second.snapshot;
p.time = time;
p.x.resize(n);
p.v.resize(n);
for (size_t i = 0u; i < n; ++i) {
p.x[i] = static_cast<double>(i);
p.v[i] = v[i];
}
}
size_t SignalStore::readLast(const std::string& name, size_t n, Series& out) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
if (it == entries_.end()) {
out.clear();
return 0u;
}
return it->second.buf.readLast(n, out.t, out.v);
}
size_t SignalStore::readRange(const std::string& name, double t0, double t1,
Series& out) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
if (it == entries_.end()) {
out.clear();
return 0u;
}
return it->second.buf.readRange(t0, t1, out.t, out.v);
}
bool SignalStore::readProfile(const std::string& name, Profile& out) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
if (it == entries_.end() || it->second.snapshot.v.empty()) {
return false;
}
out = it->second.snapshot;
return true;
}
bool SignalStore::span(const std::string& name, double& oldest, double& newest) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
if (it == entries_.end() || it->second.buf.count == 0u) {
return false;
}
oldest = oldestOf(it->second.buf);
newest = newestOf(it->second.buf);
return true;
}
double SignalStore::rate(const std::string& name) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
return (it == entries_.end()) ? 0.0 : it->second.rate;
}
size_t SignalStore::capacity(const std::string& name) const {
std::lock_guard<std::mutex> lk(mu_);
std::map<std::string, Entry>::const_iterator it = entries_.find(name);
return (it == entries_.end()) ? 0u : it->second.buf.capacity;
}
void SignalStore::setWindowSec(double windowSec) {
std::lock_guard<std::mutex> lk(mu_);
if (windowSec > 0.0) {
windowSec_ = windowSec;
}
}
double SignalStore::windowSec() const {
std::lock_guard<std::mutex> lk(mu_);
return windowSec_;
}
size_t SignalStore::targetCapacity(const Entry& e, size_t ringCount) const {
if (e.rate <= 0.0) {
return kMinRingPoints;
}
const double want = e.rate * windowSec_ * kRingMargin;
size_t cap = (want >= static_cast<double>(kMaxRingPoints))
? kMaxRingPoints
: static_cast<size_t>(want);
const size_t share = kTotalPointBudget / std::max<size_t>(1u, ringCount);
cap = std::min(cap, share);
cap = std::min(cap, kMaxRingPoints);
return std::max(cap, kMinRingPoints);
}
void SignalStore::maintain() {
std::lock_guard<std::mutex> lk(mu_);
const size_t ringCount = entries_.size();
std::vector<double> t, v;
for (std::map<std::string, Entry>::iterator it = entries_.begin();
it != entries_.end(); ++it) {
Entry& e = it->second;
if (e.profile) {
continue;
}
const size_t want = targetCapacity(e, ringCount);
const double cur = static_cast<double>(e.buf.capacity);
if (std::fabs(static_cast<double>(want) - cur) <= kResizeHysteresis * cur) {
continue;
}
/* setCapacity() clears, so read the survivors out and push them back. */
const size_t keep = std::min(want, e.buf.count);
e.buf.readLast(keep, t, v);
e.buf.setCapacity(want);
for (size_t i = 0u; i < t.size(); ++i) {
e.buf.push(t[i], v[i]);
}
}
}
void SignalStore::publishCapture(Capture&& c) {
std::lock_guard<std::mutex> lk(mu_);
++captureSeq_;
capture_ = std::move(c);
capture_.seq = captureSeq_;
}
uint64_t SignalStore::captureSeq() const {
std::lock_guard<std::mutex> lk(mu_);
return captureSeq_;
}
bool SignalStore::readCapture(Capture& out) const {
std::lock_guard<std::mutex> lk(mu_);
if (captureSeq_ == 0u) {
return false;
}
out = capture_;
return true;
}
} /* namespace udpscope */
- Step 5: Register the source with CMake
In Client/udpscope/CMakeLists.txt, extend CORE_SOURCES:
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
)
The tests now use threads, so link them:
find_package(Threads REQUIRED)
target_link_libraries(udpscope_core PUBLIC Threads::Threads)
- Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='SignalStore*'
Expected: PASS, 15 tests.
If MaintainSharesTheGlobalBudgetBetweenSignals reports an unused budget,
check that targetCapacity() divides by the number of rings, not the
number of assigned signals.
- Step 7: Commit
git add Client/udpscope/SignalStore.h Client/udpscope/SignalStore.cpp \
Client/udpscope/tests/SignalStoreTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): mutex-guarded signal store with window-driven ring sizing"
Task 7: Receiver — frame path (no thread, no socket)
The receiver's decision-making is factored out of the C callback into plain methods so it can be driven from tests with synthetic frames. This task writes those methods and their tests; Task 8 bolts the thread and the C client onto the same class.
Files:
- Create:
Client/udpscope/Receiver.h - Create:
Client/udpscope/Receiver.cpp - Modify:
Client/udpscope/CMakeLists.txt(addReceiver.cpptoCORE_SOURCES) - Test:
Client/udpscope/tests/ReceiverTest.cpp
Interfaces:
- Consumes:
SignalStore,Capture(Task 6);Trigger,TrigConfig,TrigState,TrigMode(Task 5);FrameDecoder(Task 4);SignalMeta,FrameView,Series(Tasks 1/4). - Produces:
struct TrigStatus {
TrigState state = TrigState::Idle;
double fill = 0.0;
double trigTime = 0.0;
uint64_t captures = 0u;
};
class Receiver {
public:
explicit Receiver(SignalStore& store);
void handleConfig(const std::vector<SignalMeta>& metas);
void handleFrame(const FrameView& f);
void setTrigConfig(const TrigConfig& c);
TrigConfig trigConfig() const;
void arm();
void disarm();
void rearm();
TrigStatus trigStatus() const;
};
Threading contract, stated once and relied on everywhere below:
handleConfig/handleFrame run only on the receiver thread. The GUI calls
only setTrigConfig/arm/disarm/rearm/trigConfig/trigStatus, which
take ctlMu_ and never touch the Trigger directly — commands are queued
and applied at the top of the next frame. ctlMu_ is never held across a
SignalStore call, so the two locks cannot deadlock.
- Step 1: Write the failing tests
Create Client/udpscope/tests/ReceiverTest.cpp:
#include "Receiver.h"
#include <gtest/gtest.h>
#include <cmath>
#include <string>
#include <vector>
using namespace udpscope;
namespace {
// Owns the value arrays for one synthetic frame and hands out a FrameView
// pointing into them. Keep the Synth alive for as long as the view is used.
struct Synth {
std::vector<std::vector<double> > vals;
std::vector<const double*> ptrs;
std::vector<uint32_t> counts;
void add(const std::vector<double>& v) { vals.push_back(v); }
FrameView view(uint32_t counter, uint64_t hrt, double recvTime,
uint32_t numSamples) {
ptrs.clear();
counts.clear();
for (size_t i = 0; i < vals.size(); ++i) {
ptrs.push_back(vals[i].data());
counts.push_back(static_cast<uint32_t>(vals[i].size()));
}
FrameView f;
f.counter = counter;
f.hrt = hrt;
f.recvTime = recvTime;
f.numSamples = numSamples;
f.numSignals = static_cast<uint32_t>(vals.size());
f.values = ptrs.data();
f.counts = counts.data();
return f;
}
};
SignalMeta scalar(const std::string& name) {
SignalMeta m;
m.name = name;
m.typeCode = 8; /* float32 */
m.numRows = 1;
m.numCols = 1;
m.timeMode = kTimePacket;
return m;
}
// A uint64 nanosecond time signal, as TimeArrayGAM emits.
SignalMeta timeSignal(const std::string& name, uint32_t nElems) {
SignalMeta m;
m.name = name;
m.typeCode = 6; /* uint64 -> 1e-9 scale */
m.numRows = 1;
m.numCols = nElems;
m.timeMode = kTimeFullArray;
return m;
}
// A burst signal anchored on its first sample, paired with time signal 0.
SignalMeta burst(const std::string& name, uint32_t nElems, double rate) {
SignalMeta m;
m.name = name;
m.typeCode = 8;
m.numRows = 1;
m.numCols = nElems;
m.timeMode = kTimeFirstSample;
m.samplingRate = rate;
m.timeSignalIdx = 0u;
return m;
}
} // namespace
TEST(Receiver, ConfigCreatesTheStoreSignals) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a"), scalar("b")});
EXPECT_EQ(store.signals().size(), 2u);
EXPECT_EQ(store.capacity("a"), SignalStore::kMinRingPoints);
}
TEST(Receiver, ScalarFramesLandInTheRingAtArrivalTime) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a")});
for (int i = 0; i < 3; ++i) {
Synth s;
s.add({static_cast<double>(i)});
FrameView f = s.view(static_cast<uint32_t>(i), 0u, 1.0 + 0.01 * i, 1u);
rx.handleFrame(f);
}
Series out;
ASSERT_EQ(store.readLast("a", 10, out), 3u);
EXPECT_NEAR(out.t[0], 1.00, 1e-9);
EXPECT_NEAR(out.t[2], 1.02, 1e-9);
EXPECT_DOUBLE_EQ(out.v[2], 2.0);
}
// A burst arrives as one packet but must be unrolled onto the time axis, or
// the scope draws a staircase at the packet rate instead of the waveform.
TEST(Receiver, BurstsAreUnrolledOntoTheTimeAxis) {
SignalStore store;
Receiver rx(store);
const uint32_t N = 8u;
rx.handleConfig({timeSignal("t", N), burst("a", N, 10000.0)});
Synth s;
std::vector<double> ts(N), vs(N);
for (uint32_t e = 0; e < N; ++e) {
ts[e] = 1.0e9 + static_cast<double>(e) * 1.0e5; /* 1 s + e * 0.1 ms, in ns */
vs[e] = static_cast<double>(e);
}
s.add(ts);
s.add(vs);
FrameView f = s.view(0u, 0u, 50.0, N);
rx.handleFrame(f);
Series out;
ASSERT_EQ(store.readLast("a", 100, out), N);
// Rule 2 anchors on the time signal and spreads by 1/10 kHz = 0.1 ms.
for (uint32_t e = 1; e < N; ++e) {
EXPECT_NEAR(out.t[e] - out.t[e - 1u], 1.0e-4, 1e-9) << "element " << e;
}
EXPECT_DOUBLE_EQ(out.v[N - 1u], static_cast<double>(N - 1u));
}
TEST(Receiver, VectorProfilesGoToTheSnapshotSlotNotTheRing) {
SignalStore store;
Receiver rx(store);
SignalMeta m = scalar("vec");
m.numCols = 4u;
m.timeMode = kTimePacket;
m.profileOverride = true;
ASSERT_TRUE(m.isVectorProfile());
rx.handleConfig({m});
Synth s;
s.add({1.0, 2.0, 3.0, 4.0});
FrameView f = s.view(0u, 0u, 7.0, 1u);
rx.handleFrame(f);
Series ring;
EXPECT_EQ(store.readLast("vec", 10, ring), 0u) << "profile leaked into the ring";
Profile p;
ASSERT_TRUE(store.readProfile("vec", p));
EXPECT_DOUBLE_EQ(p.time, 7.0);
ASSERT_EQ(p.v.size(), 4u);
EXPECT_DOUBLE_EQ(p.v[3], 4.0);
}
TEST(Receiver, TriggerCommandsTakeEffectOnTheNextFrame) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a")});
TrigConfig c;
c.signalName = "a";
c.threshold = 0.5;
c.windowSec = 0.02;
c.prePercent = 25.0;
rx.setTrigConfig(c);
EXPECT_EQ(rx.trigStatus().state, TrigState::Idle);
rx.arm();
EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "applied without a frame";
Synth s;
s.add({0.0});
FrameView f = s.view(0u, 0u, 1.0, 1u);
rx.handleFrame(f);
EXPECT_EQ(rx.trigStatus().state, TrigState::Armed);
EXPECT_DOUBLE_EQ(store.windowSec(), 0.02) << "window not forwarded to the store";
}
// The whole trigger path, end to end: a step on the trigger signal produces a
// capture holding every scalar signal over the configured window.
TEST(Receiver, AReadyCaptureIsPublishedWithEverySignal) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a"), scalar("b")});
TrigConfig c;
c.signalName = "a";
c.edge = Edge::Rising;
c.threshold = 0.5;
c.windowSec = 0.020; /* pre 0.005, post 0.015 */
c.prePercent = 25.0;
c.mode = TrigMode::Normal;
rx.setTrigConfig(c);
rx.arm();
// 1 kHz of scalar frames; "a" steps high at t = 1.100.
double t = 1.000;
for (int i = 0; i < 300; ++i, t += 0.001) {
Synth s;
s.add({(t >= 1.100) ? 1.0 : 0.0});
s.add({static_cast<double>(i)});
FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
rx.handleFrame(f);
}
EXPECT_EQ(rx.trigStatus().captures, 1u);
Capture cap;
ASSERT_TRUE(store.readCapture(cap));
EXPECT_NEAR(cap.trigTime, 1.0995, 1e-6);
ASSERT_EQ(cap.names.size(), 2u);
ASSERT_EQ(cap.series.size(), 2u);
EXPECT_GT(cap.series[0].t.size(), 10u);
EXPECT_LE(cap.series[0].t.front(), cap.trigTime);
EXPECT_GE(cap.series[0].t.back(), cap.trigTime);
EXPECT_NEAR(cap.t0, cap.trigTime - 0.005, 1e-9);
EXPECT_NEAR(cap.t1, cap.trigTime + 0.015, 1e-9);
}
TEST(Receiver, NormalModeKeepsCapturingAndSingleModeStops) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a")});
TrigConfig c;
c.signalName = "a";
c.threshold = 0.5;
c.windowSec = 0.020;
c.prePercent = 25.0;
c.mode = TrigMode::Normal;
rx.setTrigConfig(c);
rx.arm();
// Square wave: 50 ms high, 50 ms low, sampled at 1 kHz for 1 s.
double t = 1.000;
for (int i = 0; i < 1000; ++i, t += 0.001) {
Synth s;
s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0});
FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
rx.handleFrame(f);
}
const uint64_t normalCaptures = rx.trigStatus().captures;
EXPECT_GE(normalCaptures, 5u) << "normal mode stopped re-arming";
EXPECT_EQ(rx.trigStatus().state, TrigState::Armed);
c.mode = TrigMode::Single;
rx.setTrigConfig(c); /* resets the FSM to Idle */
rx.arm();
for (int i = 0; i < 1000; ++i, t += 0.001) {
Synth s;
s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0});
FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
rx.handleFrame(f);
}
EXPECT_EQ(rx.trigStatus().state, TrigState::Held);
EXPECT_EQ(rx.trigStatus().captures, normalCaptures + 1u);
}
// A streamer restart re-sends CONFIG. Old samples are on the old time base and
// old signal set, so they must not survive.
TEST(Receiver, AMidStreamReconfigureDropsTheOldSamples) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({scalar("a")});
Synth s;
s.add({1.0});
FrameView f = s.view(0u, 0u, 1.0, 1u);
rx.handleFrame(f);
Series out;
ASSERT_EQ(store.readLast("a", 10, out), 1u);
rx.handleConfig({scalar("a"), scalar("b")});
EXPECT_EQ(store.readLast("a", 10, out), 0u);
EXPECT_EQ(store.signals().size(), 2u);
EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "trigger kept across CONFIG";
}
// packetBurst() cannot stamp the first packet of a stream — there is no
// previous arrival to span from. Those samples must be dropped, not stored at
// invented times.
TEST(Receiver, UnstampableSamplesAreDroppedRatherThanInvented) {
SignalStore store;
Receiver rx(store);
SignalMeta m = scalar("a");
m.numCols = 4u; /* PACKET burst, no time signal, no rate */
rx.handleConfig({m});
Synth s1;
s1.add({1.0, 2.0, 3.0, 4.0});
FrameView f1 = s1.view(0u, 0u, 1.0, 4u);
rx.handleFrame(f1);
Series out;
EXPECT_EQ(store.readLast("a", 10, out), 0u) << "first packet stamped anyway";
Synth s2;
s2.add({5.0, 6.0, 7.0, 8.0});
FrameView f2 = s2.view(1u, 0u, 1.01, 4u);
rx.handleFrame(f2);
ASSERT_EQ(store.readLast("a", 10, out), 4u);
EXPECT_GT(out.t.front(), 1.0);
EXPECT_NEAR(out.t.back(), 1.01, 1e-9);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Receiver.h: No such file or directory.
- Step 3: Write the header
Create Client/udpscope/Receiver.h:
/**
* @file Receiver.h
* @brief Frame-path logic for the UDPS receiver.
*
* handleConfig()/handleFrame() run on the receiver thread only. The GUI thread
* uses the trigger control methods, which queue their effect under ctlMu_ and
* are applied at the top of the next frame. ctlMu_ is never held across a
* SignalStore call.
*/
#ifndef UDPSCOPE_RECEIVER_H
#define UDPSCOPE_RECEIVER_H
#include "FrameDecoder.h"
#include "SignalStore.h"
#include "Trigger.h"
#include "Types.h"
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
/** Snapshot of the trigger FSM, published for the GUI once per frame. */
struct TrigStatus {
TrigState state = TrigState::Idle;
double fill = 0.0;
double trigTime = 0.0;
uint64_t captures = 0u;
};
class Receiver {
public:
explicit Receiver(SignalStore& store) : store_(store) {}
/* --- receiver thread ------------------------------------------------ */
/** Apply a new signal set: resets the decoder, store and trigger. */
void handleConfig(const std::vector<SignalMeta>& metas);
/** Decode one frame into the store, feed the trigger, harvest a capture. */
void handleFrame(const FrameView& f);
/* --- GUI thread ----------------------------------------------------- */
/** Also forwards windowSec to the store so the rings are resized. */
void setTrigConfig(const TrigConfig& c);
TrigConfig trigConfig() const;
void arm();
void disarm();
void rearm();
TrigStatus trigStatus() const;
private:
enum class Cmd { None, Arm, Disarm, Rearm };
void applyPending();
void harvestCapture();
void publishStatus();
SignalStore& store_;
FrameDecoder decoder_;
Trigger trig_;
std::vector<double> ts_; /**< scratch, receiver thread only */
mutable std::mutex ctlMu_;
TrigConfig cfg_;
bool cfgDirty_ = false;
Cmd cmd_ = Cmd::None;
TrigStatus status_;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_RECEIVER_H */
- Step 4: Write the implementation
Create Client/udpscope/Receiver.cpp:
#include "Receiver.h"
namespace udpscope {
void Receiver::handleConfig(const std::vector<SignalMeta>& metas) {
decoder_.reset();
decoder_.setSignals(metas);
store_.setSignals(metas);
/* Samples from before the reconfigure are on a different time base and a
different signal set; a capture spanning both would be nonsense. */
trig_.disarm();
publishStatus();
}
void Receiver::applyPending() {
TrigConfig cfg;
bool dirty = false;
Cmd cmd = Cmd::None;
{
std::lock_guard<std::mutex> lk(ctlMu_);
dirty = cfgDirty_;
cfg = cfg_;
cfgDirty_ = false;
cmd = cmd_;
cmd_ = Cmd::None;
}
if (dirty) {
trig_.setConfig(cfg);
}
switch (cmd) {
case Cmd::Arm: trig_.arm(); break;
case Cmd::Disarm: trig_.disarm(); break;
case Cmd::Rearm: trig_.rearm(); break;
case Cmd::None: break;
}
}
void Receiver::handleFrame(const FrameView& f) {
applyPending();
decoder_.beginFrame(f);
const std::vector<SignalMeta>& metas = decoder_.signals();
const std::string trigName = trig_.config().signalName;
for (uint32_t i = 0u; i < metas.size() && i < f.numSignals; i++) {
const SignalMeta& m = metas[i];
const uint32_t count = (f.counts != nullptr) ? f.counts[i] : 0u;
const double* vals = (f.values != nullptr) ? f.values[i] : nullptr;
if (count == 0u || vals == nullptr) {
continue;
}
if (m.isVectorProfile()) {
/* Plot against element index; only the newest snapshot matters. */
const uint32_t n = m.numElements();
const uint32_t take = (count >= n) ? n : count;
store_.pushProfile(m.name, f.recvTime, vals + (count - take), take);
continue;
}
if (!decoder_.timestamps(f, i, ts_) || ts_.size() != count) {
continue; /* unstampable: drop rather than invent times */
}
store_.push(m.name, ts_.data(), vals, count);
if (!trigName.empty() && m.name == trigName) {
double oldest = ts_[0];
double newest = 0.0;
(void) store_.span(m.name, oldest, newest);
trig_.feed(ts_.data(), vals, count, oldest);
}
}
if (trig_.captureReady()) {
harvestCapture();
}
publishStatus();
}
void Receiver::harvestCapture() {
const TrigConfig& c = trig_.config();
Capture cap;
cap.trigTime = trig_.trigTime();
cap.t0 = cap.trigTime - c.preSec();
cap.t1 = cap.trigTime + c.postSec();
const std::vector<SignalMeta>& metas = decoder_.signals();
for (size_t i = 0u; i < metas.size(); i++) {
if (metas[i].isVectorProfile()) {
continue;
}
Series s;
store_.readRange(metas[i].name, cap.t0, cap.t1, s);
cap.names.push_back(metas[i].name);
cap.series.push_back(s);
}
store_.publishCapture(std::move(cap));
trig_.captureTaken();
}
void Receiver::publishStatus() {
std::lock_guard<std::mutex> lk(ctlMu_);
status_.state = trig_.state();
status_.fill = trig_.fillFraction();
status_.trigTime = trig_.trigTime();
status_.captures = store_.captureSeq();
}
void Receiver::setTrigConfig(const TrigConfig& c) {
{
std::lock_guard<std::mutex> lk(ctlMu_);
cfg_ = c;
cfgDirty_ = true;
/* The FSM will reset on apply; reflect that immediately so the GUI does
not show a stale Armed badge for a frame. */
status_.state = TrigState::Idle;
status_.fill = 0.0;
}
/* Outside the lock: the store has its own mutex. */
store_.setWindowSec(c.windowSec);
}
TrigConfig Receiver::trigConfig() const {
std::lock_guard<std::mutex> lk(ctlMu_);
return cfg_;
}
void Receiver::arm() {
std::lock_guard<std::mutex> lk(ctlMu_);
cmd_ = Cmd::Arm;
}
void Receiver::disarm() {
std::lock_guard<std::mutex> lk(ctlMu_);
cmd_ = Cmd::Disarm;
}
void Receiver::rearm() {
std::lock_guard<std::mutex> lk(ctlMu_);
cmd_ = Cmd::Rearm;
}
TrigStatus Receiver::trigStatus() const {
std::lock_guard<std::mutex> lk(ctlMu_);
return status_;
}
} /* namespace udpscope */
publishStatus() reads store_.captureSeq() while holding ctlMu_. That is
the one place where the store lock is taken under ctlMu_; it is safe only
because no SignalStore method ever calls back into Receiver. Keep it that
way.
- Step 5: Register the source with CMake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
)
- Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Receiver*'
Expected: PASS, 9 tests.
If AReadyCaptureIsPublishedWithEverySignal reports zero captures, check
that the fill gate is being fed the ring's oldest time and not the frame's
first timestamp — the trigger cannot fire until preSec of history exists.
- Step 7: Run the whole suite
cd Client/udpscope && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–7.
- Step 8: Commit
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
Client/udpscope/tests/ReceiverTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver frame path with trigger control and capture harvest"
Task 8: Receiver — thread, C client wiring and profile overrides
Bolt the socket and the thread onto Receiver, translate the C structs into
the framework-free ones, and add the per-signal "this is a vector, not a
burst" override the spec requires for ambiguous PACKET signals. The task
ships udpscope_rxprobe, a console tool that runs the receiver against a
real streamer and prints the link counters — that is how the socket path is
verified, and it stays in the tree as a diagnostic.
Files:
- Modify:
Client/udpscope/Receiver.h(thread, options, link status, overrides) - Modify:
Client/udpscope/Receiver.cpp(same) - Create:
Client/udpscope/tools/rxprobe.cpp - Modify:
Client/udpscope/CMakeLists.txt(add theudpscope_rxprobetarget) - Test:
Client/udpscope/tests/ReceiverLinkTest.cpp
Interfaces:
- Consumes: everything from Task 7, plus the C API from
Common/Client/c/udps_client.h(udps_client_config_init,udps_client_create,udps_client_set_callbacks,udps_client_poll,udps_client_destroy,udps_client_is_connected,udps_client_stats,udps_client_last_error). - Produces:
struct ReceiverOptions {
std::string host = "127.0.0.1";
uint16_t port = 44500u;
std::string multicastGroup; // empty = unicast
std::string interfaceAddr;
uint16_t dataPort = 0u;
double silenceTimeoutSec = 2.0;
};
struct LinkStatus {
bool running = false, connected = false, haveConfig = false;
uint64_t packets = 0u, frames = 0u, configUpdates = 0u;
uint64_t counterGaps = 0u, fragmentsDropped = 0u, reconnects = 0u;
double lastFrameWall = 0.0;
std::string lastEvent;
};
// added to Receiver:
bool start(const ReceiverOptions& opt, std::string& err);
void stop();
bool running() const;
LinkStatus link() const;
void setProfileOverride(const std::string& signalName, bool isProfile);
bool profileOverride(const std::string& signalName) const;
- Step 1: Write the failing tests
Create Client/udpscope/tests/ReceiverLinkTest.cpp:
#include "Receiver.h"
#include <gtest/gtest.h>
#include <string>
#include <vector>
using namespace udpscope;
namespace {
// A 4-element PACKET signal with no time signal and no declared rate: the one
// case the protocol leaves ambiguous between a time burst and a true vector.
SignalMeta ambiguous(const std::string& name) {
SignalMeta m;
m.name = name;
m.typeCode = 8;
m.numRows = 1;
m.numCols = 4;
m.timeMode = kTimePacket;
return m;
}
struct Synth {
std::vector<std::vector<double> > vals;
std::vector<const double*> ptrs;
std::vector<uint32_t> counts;
FrameView view(double recvTime) {
ptrs.clear();
counts.clear();
for (size_t i = 0; i < vals.size(); ++i) {
ptrs.push_back(vals[i].data());
counts.push_back(static_cast<uint32_t>(vals[i].size()));
}
FrameView f;
f.recvTime = recvTime;
f.numSamples = 4u;
f.numSignals = static_cast<uint32_t>(vals.size());
f.values = ptrs.data();
f.counts = counts.data();
return f;
}
};
} // namespace
TEST(ReceiverLink, DefaultsToTreatingAnAmbiguousSignalAsABurst) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({ambiguous("a")});
EXPECT_FALSE(store.signals()[0].isVectorProfile());
EXPECT_FALSE(rx.profileOverride("a"));
}
TEST(ReceiverLink, ProfileOverrideIsAppliedToTheNextFrame) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({ambiguous("a")});
rx.setProfileOverride("a", true);
EXPECT_TRUE(rx.profileOverride("a"));
Synth s;
s.vals.push_back({1.0, 2.0, 3.0, 4.0});
FrameView f1 = s.view(1.0);
rx.handleFrame(f1); /* applies the pending override, reconfigures */
Synth s2;
s2.vals.push_back({5.0, 6.0, 7.0, 8.0});
FrameView f2 = s2.view(1.01);
rx.handleFrame(f2);
ASSERT_EQ(store.signals().size(), 1u);
EXPECT_TRUE(store.signals()[0].isVectorProfile());
Profile p;
ASSERT_TRUE(store.readProfile("a", p));
EXPECT_DOUBLE_EQ(p.v[0], 5.0);
Series ring;
EXPECT_EQ(store.readLast("a", 10, ring), 0u) << "still filling the ring";
}
// The streamer re-sends CONFIG on reconnect. The user's override is a UI
// choice and must outlive that.
TEST(ReceiverLink, ProfileOverrideSurvivesAReconfigure) {
SignalStore store;
Receiver rx(store);
rx.handleConfig({ambiguous("a")});
rx.setProfileOverride("a", true);
Synth s;
s.vals.push_back({1.0, 2.0, 3.0, 4.0});
FrameView f = s.view(1.0);
rx.handleFrame(f);
ASSERT_TRUE(store.signals()[0].isVectorProfile());
rx.handleConfig({ambiguous("a"), ambiguous("b")});
ASSERT_EQ(store.signals().size(), 2u);
EXPECT_TRUE(store.signals()[0].isVectorProfile()) << "override lost on CONFIG";
EXPECT_FALSE(store.signals()[1].isVectorProfile());
}
TEST(ReceiverLink, StartsAndStopsCleanlyWithNoServerPresent) {
SignalStore store;
Receiver rx(store);
ReceiverOptions opt;
opt.host = "127.0.0.1";
opt.port = 45999; /* nothing is listening here */
std::string err;
ASSERT_TRUE(rx.start(opt, err)) << err;
EXPECT_TRUE(rx.running());
EXPECT_TRUE(rx.link().running);
rx.stop();
EXPECT_FALSE(rx.running());
EXPECT_EQ(store.captureSeq(), 0u);
}
TEST(ReceiverLink, StartIsRejectedWhileAlreadyRunning) {
SignalStore store;
Receiver rx(store);
ReceiverOptions opt;
opt.port = 45999;
std::string err;
ASSERT_TRUE(rx.start(opt, err)) << err;
EXPECT_FALSE(rx.start(opt, err));
EXPECT_FALSE(err.empty());
rx.stop();
}
TEST(ReceiverLink, StopIsSafeWhenNeverStarted) {
SignalStore store;
Receiver rx(store);
rx.stop();
EXPECT_FALSE(rx.running());
EXPECT_FALSE(rx.link().running);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — no member named 'start' in 'udpscope::Receiver'.
- Step 3: Extend the header
In Client/udpscope/Receiver.h, add above the class:
#include <atomic>
#include <map>
#include <thread>
struct udps_client; /* opaque, see Common/Client/c/udps_client.h */
/** Where to attach. Mirrors the fields of udps_client_config_t we expose. */
struct ReceiverOptions {
std::string host = "127.0.0.1";
uint16_t port = 44500u;
std::string multicastGroup; /**< empty = unicast */
std::string interfaceAddr; /**< local interface IP, not a name */
uint16_t dataPort = 0u; /**< 0 = server-chosen */
double silenceTimeoutSec = 2.0;
};
/** Link health, published once per poll for the status bar. */
struct LinkStatus {
bool running = false;
bool connected = false;
bool haveConfig = false;
uint64_t packets = 0u;
uint64_t frames = 0u;
uint64_t configUpdates = 0u;
uint64_t counterGaps = 0u;
uint64_t fragmentsDropped = 0u;
uint64_t reconnects = 0u;
double lastFrameWall = 0.0;
std::string lastEvent;
};
Add to the public section of Receiver:
~Receiver();
/** Spawns the receiver thread. @return false and fills err on failure. */
bool start(const ReceiverOptions& opt, std::string& err);
/** Joins the thread. Safe to call when not running. */
void stop();
bool running() const { return running_.load(); }
LinkStatus link() const;
/**
* Force a PACKET signal with more than one element to be read as a vector
* profile (index axis) rather than a time burst. Applied on the next frame
* and remembered across reconfigures.
*/
void setProfileOverride(const std::string& signalName, bool isProfile);
bool profileOverride(const std::string& signalName) const;
Add to the private section:
static void onConfigC(const udps_signal_t* sigs, uint32_t n,
uint8_t publishMode, void* user);
static void onDataC(const udps_frame_t* frame, void* user);
static void onEventC(udps_event_t ev, const char* detail, void* user);
void threadMain();
void updateLink();
std::vector<SignalMeta> withOverrides(const std::vector<SignalMeta>& in) const;
udps_client_t* client_ = nullptr;
std::thread thread_;
std::atomic<bool> running_{false};
ReceiverOptions opt_;
std::string hostStr_, groupStr_, ifaceStr_; /**< own the C strings */
std::vector<SignalMeta> wireMetas_; /**< last CONFIG, before overrides */
std::vector<const double*> valPtrs_; /**< per-frame scratch */
std::vector<uint32_t> valCounts_;
mutable std::mutex linkMu_;
LinkStatus link_;
ctlMu_ also gains:
std::map<std::string, bool> overrides_;
bool overridesDirty_ = false;
The #include <mutex> and <vector> already present cover the rest.
Receiver.cpp must additionally #include "udps_client.h", which resolves
through the udpsclient target's PUBLIC include directory (${CCLIENT_DIR},
set up in Task 1).
- Step 4: Rewrite
handleConfigandapplyPendingfor overrides
Replace those two functions in Client/udpscope/Receiver.cpp:
std::vector<SignalMeta> Receiver::withOverrides(
const std::vector<SignalMeta>& in) const {
std::lock_guard<std::mutex> lk(ctlMu_);
std::vector<SignalMeta> out = in;
for (size_t i = 0u; i < out.size(); i++) {
std::map<std::string, bool>::const_iterator it = overrides_.find(out[i].name);
out[i].profileOverride = (it != overrides_.end()) && it->second;
}
return out;
}
void Receiver::handleConfig(const std::vector<SignalMeta>& metas) {
wireMetas_ = metas;
const std::vector<SignalMeta> effective = withOverrides(metas);
decoder_.reset();
decoder_.setSignals(effective);
store_.setSignals(effective);
/* Samples from before the reconfigure are on a different time base and a
different signal set; a capture spanning both would be nonsense. */
trig_.disarm();
publishStatus();
}
void Receiver::applyPending() {
TrigConfig cfg;
bool dirty = false, ovDirty = false;
Cmd cmd = Cmd::None;
{
std::lock_guard<std::mutex> lk(ctlMu_);
dirty = cfgDirty_;
cfg = cfg_;
cfgDirty_ = false;
cmd = cmd_;
cmd_ = Cmd::None;
ovDirty = overridesDirty_;
overridesDirty_ = false;
}
/* Overrides first: re-reading CONFIG resets the FSM, so an arm command in
the same batch must be applied after it, not before. */
if (ovDirty && !wireMetas_.empty()) {
handleConfig(wireMetas_);
}
if (dirty) {
trig_.setConfig(cfg);
}
switch (cmd) {
case Cmd::Arm: trig_.arm(); break;
case Cmd::Disarm: trig_.disarm(); break;
case Cmd::Rearm: trig_.rearm(); break;
case Cmd::None: break;
}
}
void Receiver::setProfileOverride(const std::string& signalName, bool isProfile) {
std::lock_guard<std::mutex> lk(ctlMu_);
overrides_[signalName] = isProfile;
overridesDirty_ = true;
}
bool Receiver::profileOverride(const std::string& signalName) const {
std::lock_guard<std::mutex> lk(ctlMu_);
std::map<std::string, bool>::const_iterator it = overrides_.find(signalName);
return (it != overrides_.end()) && it->second;
}
withOverrides() takes ctlMu_ and is called from handleConfig(), which is
called from applyPending() after it has released ctlMu_. Do not move the
call inside the lock scope.
- Step 5: Add the thread and the C callbacks
Append to Client/udpscope/Receiver.cpp (and add
#include "udps_client.h", #include <cstring> at the top):
Receiver::~Receiver() {
stop();
}
void Receiver::onConfigC(const udps_signal_t* sigs, uint32_t n,
uint8_t publishMode, void* user) {
(void) publishMode;
Receiver* self = static_cast<Receiver*>(user);
std::vector<SignalMeta> metas;
metas.reserve(n);
for (uint32_t i = 0u; i < n; i++) {
SignalMeta m;
m.name = sigs[i].name;
m.typeCode = sigs[i].type_code;
m.quantType = sigs[i].quant_type;
m.numRows = (sigs[i].num_rows > 0u) ? sigs[i].num_rows : 1u;
m.numCols = (sigs[i].num_cols > 0u) ? sigs[i].num_cols : 1u;
m.rangeMin = sigs[i].range_min;
m.rangeMax = sigs[i].range_max;
m.timeMode = sigs[i].time_mode;
m.samplingRate = sigs[i].sampling_rate;
m.timeSignalIdx = sigs[i].time_signal_idx;
m.unit = sigs[i].unit;
metas.push_back(m);
}
self->handleConfig(metas);
{
std::lock_guard<std::mutex> lk(self->linkMu_);
self->link_.haveConfig = true;
}
}
void Receiver::onDataC(const udps_frame_t* frame, void* user) {
Receiver* self = static_cast<Receiver*>(user);
/* udps_frame_t stores an array of {ptr,count} structs; FrameView wants two
parallel arrays. Reuse the scratch vectors instead of allocating per
frame — this runs at the packet rate. */
self->valPtrs_.resize(frame->num_signals);
self->valCounts_.resize(frame->num_signals);
for (uint32_t i = 0u; i < frame->num_signals; i++) {
self->valPtrs_[i] = frame->values[i].values;
self->valCounts_[i] = frame->values[i].count;
}
FrameView f;
f.counter = frame->counter;
f.hrt = frame->hrt;
f.recvTime = frame->recv_time;
f.numSamples = frame->num_samples;
f.numSignals = frame->num_signals;
f.values = self->valPtrs_.data();
f.counts = self->valCounts_.data();
self->handleFrame(f);
std::lock_guard<std::mutex> lk(self->linkMu_);
self->link_.lastFrameWall = frame->recv_time;
}
void Receiver::onEventC(udps_event_t ev, const char* detail, void* user) {
Receiver* self = static_cast<Receiver*>(user);
std::lock_guard<std::mutex> lk(self->linkMu_);
switch (ev) {
case UDPS_EVENT_CONNECTED: self->link_.lastEvent = "connected"; break;
case UDPS_EVENT_DISCONNECTED: self->link_.lastEvent = "disconnected"; break;
case UDPS_EVENT_ERROR: self->link_.lastEvent = "error"; break;
}
if (detail != nullptr && detail[0] != '\0') {
self->link_.lastEvent += ": ";
self->link_.lastEvent += detail;
}
}
bool Receiver::start(const ReceiverOptions& opt, std::string& err) {
if (running_.load()) {
err = "receiver already running";
return false;
}
opt_ = opt;
/* udps_client_config_t holds borrowed const char*, so keep the storage
alive for as long as the client. */
hostStr_ = opt.host;
groupStr_ = opt.multicastGroup;
ifaceStr_ = opt.interfaceAddr;
udps_client_config_t cfg;
udps_client_config_init(&cfg);
cfg.server_addr = hostStr_.c_str();
cfg.server_port = opt.port;
cfg.multicast_group = groupStr_.empty() ? nullptr : groupStr_.c_str();
cfg.interface_addr = ifaceStr_.empty() ? nullptr : ifaceStr_.c_str();
cfg.data_port = opt.dataPort;
cfg.silence_timeout_s = opt.silenceTimeoutSec;
client_ = udps_client_create(&cfg);
if (client_ == nullptr) {
err = "udps_client_create failed (bad address or out of memory)";
return false;
}
udps_client_set_callbacks(client_, &Receiver::onConfigC, &Receiver::onDataC,
&Receiver::onEventC, this);
running_.store(true);
{
std::lock_guard<std::mutex> lk(linkMu_);
link_ = LinkStatus();
link_.running = true;
}
thread_ = std::thread(&Receiver::threadMain, this);
return true;
}
void Receiver::stop() {
if (!running_.exchange(false)) {
return;
}
if (thread_.joinable()) {
thread_.join();
}
udps_client_destroy(client_);
client_ = nullptr;
std::lock_guard<std::mutex> lk(linkMu_);
link_.running = false;
link_.connected = false;
}
void Receiver::threadMain() {
while (running_.load()) {
/* 100 ms keeps stop() responsive; the poll returns as soon as a packet
lands, so this is not a latency floor. */
(void) udps_client_poll(client_, 100);
store_.maintain();
updateLink();
}
}
void Receiver::updateLink() {
udps_stats_t st;
udps_client_stats(client_, &st);
const int connected = udps_client_is_connected(client_);
std::lock_guard<std::mutex> lk(linkMu_);
link_.connected = (connected != 0);
link_.packets = st.packets_received;
link_.frames = st.frames_delivered;
link_.configUpdates = st.config_updates;
link_.counterGaps = st.counter_gaps;
link_.fragmentsDropped = st.fragments_dropped;
link_.reconnects = st.reconnects;
}
LinkStatus Receiver::link() const {
std::lock_guard<std::mutex> lk(linkMu_);
return link_;
}
- Step 6: Write the probe tool
Create Client/udpscope/tools/rxprobe.cpp:
/**
* @file rxprobe.cpp
* @brief Headless smoke test for the UDPScope receiver.
*
* Attaches to a UDPStreamer, runs the real receiver thread and prints the link
* counters and the per-signal ring state once a second. Use it to tell a
* receiver problem from a rendering problem.
*/
#include "Receiver.h"
#include "SignalStore.h"
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
namespace {
volatile sig_atomic_t g_stop = 0;
void onSignal(int) { g_stop = 1; }
}
int main(int argc, char** argv) {
udpscope::ReceiverOptions opt;
int seconds = 0; /* 0 = until Ctrl-C */
for (int i = 1; i < argc; i++) {
const bool hasNext = (i + 1 < argc);
if (std::strcmp(argv[i], "--host") == 0 && hasNext) {
opt.host = argv[++i];
} else if (std::strcmp(argv[i], "--port") == 0 && hasNext) {
opt.port = static_cast<uint16_t>(std::atoi(argv[++i]));
} else if (std::strcmp(argv[i], "--multicast") == 0 && hasNext) {
opt.multicastGroup = argv[++i];
} else if (std::strcmp(argv[i], "--iface") == 0 && hasNext) {
opt.interfaceAddr = argv[++i];
} else if (std::strcmp(argv[i], "--data-port") == 0 && hasNext) {
opt.dataPort = static_cast<uint16_t>(std::atoi(argv[++i]));
} else if (std::strcmp(argv[i], "--seconds") == 0 && hasNext) {
seconds = std::atoi(argv[++i]);
} else {
std::printf("usage: %s [--host H] [--port P] [--multicast G] "
"[--iface IP] [--data-port P] [--seconds N]\n", argv[0]);
return (std::strcmp(argv[i], "--help") == 0) ? 0 : 2;
}
}
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
udpscope::SignalStore store;
udpscope::Receiver rx(store);
std::string err;
if (!rx.start(opt, err)) {
std::fprintf(stderr, "start failed: %s\n", err.c_str());
return 1;
}
std::printf("attached to %s:%u\n", opt.host.c_str(), opt.port);
for (int elapsed = 0; g_stop == 0 && (seconds == 0 || elapsed < seconds);
elapsed++) {
std::this_thread::sleep_for(std::chrono::seconds(1));
const udpscope::LinkStatus l = rx.link();
std::printf("[%3ds] %s pkts=%llu frames=%llu cfg=%llu gaps=%llu "
"frag=%llu recon=%llu %s\n",
elapsed + 1, l.connected ? "UP " : "DOWN",
(unsigned long long) l.packets,
(unsigned long long) l.frames,
(unsigned long long) l.configUpdates,
(unsigned long long) l.counterGaps,
(unsigned long long) l.fragmentsDropped,
(unsigned long long) l.reconnects,
l.lastEvent.c_str());
const std::vector<udpscope::SignalMeta> sigs = store.signals();
for (size_t i = 0; i < sigs.size(); i++) {
double t0 = 0.0, t1 = 0.0;
const bool have = store.span(sigs[i].name, t0, t1);
std::printf(" %-24s rate=%9.1f Hz cap=%8zu span=%.3f s\n",
sigs[i].name.c_str(), store.rate(sigs[i].name),
store.capacity(sigs[i].name), have ? (t1 - t0) : 0.0);
}
}
rx.stop();
return 0;
}
- Step 7: Add the probe target to CMake
add_executable(udpscope_rxprobe tools/rxprobe.cpp)
target_link_libraries(udpscope_rxprobe PRIVATE udpscope_core)
- Step 8: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ReceiverLink*:Receiver*'
Expected: PASS, 15 tests (9 from Task 7, 6 new).
- Step 9: Verify the socket path against a real streamer
In one terminal:
cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components
source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
-l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
-s Running -m StateMachine:START
In another:
cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components/Client/udpscope
./build/udpscope_rxprobe --host 127.0.0.1 --port 44501 --seconds 5
Expected: UP, pkts and frames climbing, one line per signal with a
plausible rate and a span that stops growing once the ring is full. gaps
and frag should stay at 0 on loopback.
Cross-check the same source with the reference dumper, which must report the same signal names and rates:
cd ../../Common/Client/c && make && \
./build/udps_dump --host 127.0.0.1 --port 44501 --frames 20
- Step 10: Commit
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
Client/udpscope/tools/rxprobe.cpp \
Client/udpscope/tests/ReceiverLinkTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver thread, UDPS client wiring and rxprobe tool"
Task 9: Command line, window bootstrap and the app shell
First runnable binary: parses the command line, opens an SDL2/OpenGL window, starts the receiver, and draws the menu bar, the signal list and the status bar. The plot area is an empty placeholder that Task 10 fills in.
Files:
- Create:
Client/udpscope/Cli.h - Create:
Client/udpscope/Cli.cpp - Create:
Client/udpscope/App.h - Create:
Client/udpscope/App.cpp - Create:
Client/udpscope/SignalList.cpp - Create:
Client/udpscope/main.cpp - Modify:
Client/udpscope/CMakeLists.txt - Test:
Client/udpscope/tests/CliTest.cpp
Interfaces:
- Consumes:
ReceiverOptions,Receiver,LinkStatus(Task 8);SignalStore(Task 6);PaneTree(Task 2). - Produces:
struct CliOptions {
ReceiverOptions source;
std::string configPath; // empty = DefaultConfigPath()
size_t maxPlotPoints = 4000u;
bool setHost = false, setPort = false, setMulticast = false;
bool setIface = false, setDataPort = false, setSilence = false;
bool setConfigPath = false, setMaxPlotPoints = false;
};
enum class CliResult { Ok, Help, Error };
CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err);
std::string CliUsage();
std::string DefaultConfigPath();
class App {
public:
explicit App(const CliOptions& opt);
~App();
void draw(); // one ImGui frame
bool wantsQuit() const;
void requestQuit();
};
Why the set* flags exist: spec §11 requires that an explicit command-line
option beats the settings file, while an omitted one falls back to it. Without
per-field "was it given" flags there is no way to tell --port 44500 from the
default, and connecting from the command line would silently override a saved
session (or vice versa). Task 15 consumes these flags.
- Step 1: Write the failing tests
Create Client/udpscope/tests/CliTest.cpp:
#include "Cli.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <string>
#include <vector>
using namespace udpscope;
namespace {
// ParseCli takes char**, as main() does.
CliResult parse(const std::vector<std::string>& args, CliOptions& out,
std::string& err) {
std::vector<char*> argv;
argv.push_back(const_cast<char*>("udpscope"));
for (size_t i = 0; i < args.size(); ++i) {
argv.push_back(const_cast<char*>(args[i].c_str()));
}
return ParseCli(static_cast<int>(argv.size()), argv.data(), out, err);
}
} // namespace
TEST(Cli, DefaultsMatchUdpsDump) {
CliOptions o;
std::string err;
ASSERT_EQ(parse({}, o, err), CliResult::Ok) << err;
EXPECT_EQ(o.source.host, "127.0.0.1");
EXPECT_EQ(o.source.port, 44500u);
EXPECT_TRUE(o.source.multicastGroup.empty());
EXPECT_EQ(o.source.dataPort, 0u);
EXPECT_FALSE(o.setHost);
EXPECT_FALSE(o.setPort);
}
TEST(Cli, ParsesEveryOptionAndMarksItAsGiven) {
CliOptions o;
std::string err;
ASSERT_EQ(parse({"--host", "10.0.0.5", "--port", "44501",
"--multicast", "239.0.0.1", "--iface", "192.168.1.2",
"--data-port", "44503", "--silence", "3.5",
"--config", "/tmp/s.conf", "--max-mpts", "8000"},
o, err), CliResult::Ok) << err;
EXPECT_EQ(o.source.host, "10.0.0.5");
EXPECT_EQ(o.source.port, 44501u);
EXPECT_EQ(o.source.multicastGroup, "239.0.0.1");
EXPECT_EQ(o.source.interfaceAddr, "192.168.1.2");
EXPECT_EQ(o.source.dataPort, 44503u);
EXPECT_DOUBLE_EQ(o.source.silenceTimeoutSec, 3.5);
EXPECT_EQ(o.configPath, "/tmp/s.conf");
EXPECT_EQ(o.maxPlotPoints, 8000u);
EXPECT_TRUE(o.setHost && o.setPort && o.setMulticast && o.setIface &&
o.setDataPort && o.setSilence && o.setConfigPath &&
o.setMaxPlotPoints);
}
TEST(Cli, HelpIsNotAnError) {
CliOptions o;
std::string err;
EXPECT_EQ(parse({"--help"}, o, err), CliResult::Help);
EXPECT_FALSE(CliUsage().empty());
}
TEST(Cli, RejectsAnUnknownOption) {
CliOptions o;
std::string err;
EXPECT_EQ(parse({"--colour", "red"}, o, err), CliResult::Error);
EXPECT_NE(err.find("--colour"), std::string::npos);
}
TEST(Cli, RejectsAMissingValue) {
CliOptions o;
std::string err;
EXPECT_EQ(parse({"--port"}, o, err), CliResult::Error);
EXPECT_NE(err.find("--port"), std::string::npos);
}
// Single-dash clustering is the trap the Qt client documents; reject it loudly
// rather than misparse it.
TEST(Cli, RejectsSingleDashOptions) {
CliOptions o;
std::string err;
EXPECT_EQ(parse({"-host", "10.0.0.5"}, o, err), CliResult::Error);
EXPECT_NE(err.find("-host"), std::string::npos);
}
TEST(Cli, RejectsAnOutOfRangePort) {
CliOptions o;
std::string err;
EXPECT_EQ(parse({"--port", "70000"}, o, err), CliResult::Error);
EXPECT_EQ(parse({"--port", "abc"}, o, err), CliResult::Error);
}
TEST(Cli, DefaultConfigPathFollowsXdg) {
const char* old = std::getenv("XDG_CONFIG_HOME");
const std::string saved = (old != nullptr) ? old : "";
setenv("XDG_CONFIG_HOME", "/tmp/xdg-test", 1);
EXPECT_EQ(DefaultConfigPath(), "/tmp/xdg-test/udpscope/session.conf");
unsetenv("XDG_CONFIG_HOME");
const std::string home = DefaultConfigPath();
EXPECT_NE(home.find("/.config/udpscope/session.conf"), std::string::npos);
if (!saved.empty()) {
setenv("XDG_CONFIG_HOME", saved.c_str(), 1);
}
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Cli.h: No such file or directory.
- Step 3: Write
Cli.handCli.cpp
Create Client/udpscope/Cli.h:
/**
* @file Cli.h
* @brief Command-line parsing, mirroring Common/Client/c/example/udps_dump.c.
*
* Every field records whether it was given explicitly, because an explicit
* option must beat the settings file while an omitted one must not.
*/
#ifndef UDPSCOPE_CLI_H
#define UDPSCOPE_CLI_H
#include "Receiver.h"
#include <cstddef>
#include <string>
namespace udpscope {
struct CliOptions {
ReceiverOptions source;
std::string configPath; /**< empty = DefaultConfigPath() */
size_t maxPlotPoints = 4000u; /**< decimation budget per trace */
bool setHost = false;
bool setPort = false;
bool setMulticast = false;
bool setIface = false;
bool setDataPort = false;
bool setSilence = false;
bool setConfigPath = false;
bool setMaxPlotPoints = false;
};
enum class CliResult { Ok, Help, Error };
CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err);
std::string CliUsage();
/** $XDG_CONFIG_HOME/udpscope/session.conf, else ~/.config/... */
std::string DefaultConfigPath();
} /* namespace udpscope */
#endif /* UDPSCOPE_CLI_H */
Create Client/udpscope/Cli.cpp:
#include "Cli.h"
#include <cstdlib>
#include <cstring>
#include <string>
namespace udpscope {
namespace {
bool parseUInt(const char* s, unsigned long& out) {
if (s == nullptr || s[0] == '\0') {
return false;
}
char* end = nullptr;
const unsigned long v = std::strtoul(s, &end, 10);
if (end == s || *end != '\0') {
return false;
}
out = v;
return true;
}
bool parseDouble(const char* s, double& out) {
if (s == nullptr || s[0] == '\0') {
return false;
}
char* end = nullptr;
const double v = std::strtod(s, &end);
if (end == s || *end != '\0') {
return false;
}
out = v;
return true;
}
} /* namespace */
std::string CliUsage() {
return "usage: udpscope [--host ADDR] [--port N] [--multicast GROUP]\n"
" [--iface ADDR] [--data-port N] [--silence SEC]\n"
" [--config PATH] [--max-mpts N] [--help]\n"
"\n"
" --host ADDR streamer address (default 127.0.0.1)\n"
" --port N streamer control port (default 44500)\n"
" --multicast GROUP join this multicast group for data\n"
" --iface ADDR local interface IP (an address, not a name)\n"
" --data-port N local data port (default: server-chosen)\n"
" --silence SEC reconnect after this much silence (default 2)\n"
" --config PATH settings file (default XDG session.conf)\n"
" --max-mpts N max plotted points per trace (default 4000)\n";
}
std::string DefaultConfigPath() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
if (xdg != nullptr && xdg[0] != '\0') {
return std::string(xdg) + "/udpscope/session.conf";
}
const char* home = std::getenv("HOME");
const std::string base = (home != nullptr && home[0] != '\0') ? home : ".";
return base + "/.config/udpscope/session.conf";
}
CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err) {
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
const bool hasNext = (i + 1 < argc);
const char* next = hasNext ? argv[i + 1] : nullptr;
if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
return CliResult::Help;
}
/* Long options only. A single dash is silently reinterpreted as
clustered short flags by some parsers; refuse it instead. */
if (a[0] != '-' || a[1] != '-') {
err = std::string("unexpected argument '") + a +
"' (long -- options only)";
return CliResult::Error;
}
unsigned long u = 0u;
double d = 0.0;
if (std::strcmp(a, "--host") == 0) {
if (!hasNext) { err = "--host needs an address"; return CliResult::Error; }
out.source.host = next;
out.setHost = true;
i++;
} else if (std::strcmp(a, "--port") == 0) {
if (!hasNext || !parseUInt(next, u) || u == 0u || u > 65535u) {
err = "--port needs a number in 1..65535";
return CliResult::Error;
}
out.source.port = static_cast<uint16_t>(u);
out.setPort = true;
i++;
} else if (std::strcmp(a, "--multicast") == 0) {
if (!hasNext) { err = "--multicast needs a group"; return CliResult::Error; }
out.source.multicastGroup = next;
out.setMulticast = true;
i++;
} else if (std::strcmp(a, "--iface") == 0) {
if (!hasNext) { err = "--iface needs an address"; return CliResult::Error; }
out.source.interfaceAddr = next;
out.setIface = true;
i++;
} else if (std::strcmp(a, "--data-port") == 0) {
if (!hasNext || !parseUInt(next, u) || u > 65535u) {
err = "--data-port needs a number in 0..65535";
return CliResult::Error;
}
out.source.dataPort = static_cast<uint16_t>(u);
out.setDataPort = true;
i++;
} else if (std::strcmp(a, "--silence") == 0) {
if (!hasNext || !parseDouble(next, d) || d <= 0.0) {
err = "--silence needs a positive number of seconds";
return CliResult::Error;
}
out.source.silenceTimeoutSec = d;
out.setSilence = true;
i++;
} else if (std::strcmp(a, "--config") == 0) {
if (!hasNext) { err = "--config needs a path"; return CliResult::Error; }
out.configPath = next;
out.setConfigPath = true;
i++;
} else if (std::strcmp(a, "--max-mpts") == 0) {
if (!hasNext || !parseUInt(next, u) || u < 100u) {
err = "--max-mpts needs a number of at least 100";
return CliResult::Error;
}
out.maxPlotPoints = static_cast<size_t>(u);
out.setMaxPlotPoints = true;
i++;
} else {
err = std::string("unknown option '") + a + "'";
return CliResult::Error;
}
}
return CliResult::Ok;
}
} /* namespace udpscope */
- Step 4: Run the CLI tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Cli*'
Expected: PASS, 8 tests. Add Cli.cpp to CORE_SOURCES first:
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
Cli.cpp
)
- Step 5: Write the app shell header
Create Client/udpscope/App.h:
/**
* @file App.h
* @brief GUI-thread application state and per-frame drawing.
*
* Owns the store, the receiver and the pane tree. Everything here runs on the
* GUI thread; the only cross-thread access is through SignalStore and the
* Receiver's control methods.
*/
#ifndef UDPSCOPE_APP_H
#define UDPSCOPE_APP_H
#include "Cli.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "SignalStore.h"
#include <string>
#include <vector>
namespace udpscope {
class App {
public:
explicit App(const CliOptions& opt);
~App();
/** Draw one frame. Call between ImGui::NewFrame() and ImGui::Render(). */
void draw();
bool wantsQuit() const { return quit_; }
void requestQuit() { quit_ = true; }
private:
void drawMenuBar();
void drawSignalList();
void drawPlotArea();
void drawStatusBar();
/** Refresh the cached signal list when the store's generation changes. */
void syncSignals();
CliOptions opt_;
SignalStore store_;
Receiver rx_;
PaneTree tree_;
std::vector<SignalMeta> sigs_;
uint64_t sigGeneration_ = 0u;
std::string status_;
bool quit_ = false;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_APP_H */
- Step 6: Write the app shell implementation
Create Client/udpscope/App.cpp:
#include "App.h"
#include "imgui.h"
#include "implot.h"
#include <cinttypes>
#include <cstdio>
namespace udpscope {
App::App(const CliOptions& opt) : opt_(opt), rx_(store_) {
std::string err;
if (!rx_.start(opt_.source, err)) {
status_ = "receiver failed to start: " + err;
} else {
char buf[128];
std::snprintf(buf, sizeof(buf), "attaching to %s:%u",
opt_.source.host.c_str(),
static_cast<unsigned>(opt_.source.port));
status_ = buf;
}
}
App::~App() {
rx_.stop();
}
void App::syncSignals() {
const uint64_t gen = store_.generation();
if (gen != sigGeneration_) {
sigGeneration_ = gen;
sigs_ = store_.signals();
/* Assignments naming a signal that is gone are pruned by the pane
drawing code in Task 10; nothing to do here yet. */
}
}
void App::drawMenuBar() {
if (!ImGui::BeginMainMenuBar()) {
return;
}
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Quit", "Ctrl+Q")) {
requestQuit();
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Help")) {
ImGui::MenuItem("UDPScope — direct UDPS oscilloscope", nullptr, false, false);
ImGui::EndMenu();
}
/* Connection badge, right-aligned. */
const LinkStatus l = rx_.link();
const char* text = l.connected ? "CONNECTED" : (l.running ? "connecting..." : "stopped");
const float w = ImGui::CalcTextSize(text).x;
ImGui::SameLine(ImGui::GetWindowWidth() - w - 16.0f);
ImGui::TextColored(l.connected ? ImVec4(0.65f, 0.89f, 0.63f, 1.0f)
: ImVec4(0.98f, 0.70f, 0.53f, 1.0f),
"%s", text);
ImGui::EndMainMenuBar();
}
void App::drawPlotArea() {
/* Task 10 replaces this with the pane tree. */
ImGui::TextDisabled("drag a signal here (panes land in Task 10)");
}
void App::drawStatusBar() {
const LinkStatus l = rx_.link();
ImGui::Text("pkts %" PRIu64 " frames %" PRIu64 " cfg %" PRIu64,
l.packets, l.frames, l.configUpdates);
ImGui::SameLine();
/* Gaps and dropped fragments are the honest signal that the scope is not
seeing everything, so they are highlighted rather than buried. */
const ImVec4 bad(0.95f, 0.55f, 0.66f, 1.0f);
const ImVec4 ok(0.65f, 0.68f, 0.75f, 1.0f);
ImGui::TextColored(l.counterGaps > 0u ? bad : ok, "gaps %" PRIu64, l.counterGaps);
ImGui::SameLine();
ImGui::TextColored(l.fragmentsDropped > 0u ? bad : ok,
"frag %" PRIu64, l.fragmentsDropped);
ImGui::SameLine();
ImGui::TextColored(l.reconnects > 0u ? bad : ok,
"reconn %" PRIu64, l.reconnects);
if (!status_.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("| %s", status_.c_str());
}
}
void App::draw() {
syncSignals();
drawMenuBar();
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoSavedSettings;
ImGui::Begin("##root", nullptr, flags);
const float statusH = ImGui::GetTextLineHeightWithSpacing() + 8.0f;
const float bodyH = ImGui::GetContentRegionAvail().y - statusH;
ImGui::BeginChild("##list", ImVec2(220.0f, bodyH), true);
drawSignalList();
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##panes", ImVec2(0.0f, bodyH), true);
drawPlotArea();
ImGui::EndChild();
ImGui::Separator();
drawStatusBar();
ImGui::End();
if (ImGui::IsKeyDown(ImGuiKey_ModCtrl) && ImGui::IsKeyPressed(ImGuiKey_Q)) {
requestQuit();
}
}
} /* namespace udpscope */
- Step 6b: Write
SignalList.cpp
The side panel gets its own translation unit, as spec §3.4 lays out. It is
still an App method, so it needs no accessors for the state it reads.
Create Client/udpscope/SignalList.cpp:
#include "App.h"
#include "imgui.h"
namespace udpscope {
void App::drawSignalList() {
ImGui::TextUnformatted("Signals");
ImGui::Separator();
if (sigs_.empty()) {
ImGui::TextDisabled("waiting for CONFIG");
return;
}
for (size_t i = 0u; i < sigs_.size(); i++) {
const SignalMeta& m = sigs_[i];
ImGui::PushID(static_cast<int>(i));
ImGui::Selectable(m.name.c_str());
if (ImGui::IsItemHovered()) {
double t0 = 0.0, t1 = 0.0;
const bool have = store_.span(m.name, t0, t1);
ImGui::SetTooltip("%s\n%u element(s), %s\n%.1f Hz, %.2f s buffered",
m.name.c_str(), m.numElements(),
m.unit.empty() ? "no unit" : m.unit.c_str(),
store_.rate(m.name), have ? (t1 - t0) : 0.0);
}
ImGui::PopID();
}
}
} /* namespace udpscope */
- Step 7: Write
main.cpp
Create Client/udpscope/main.cpp:
/**
* @file main.cpp
* @brief SDL2 + OpenGL 3.3 + Dear ImGui bootstrap for UDPScope.
*/
#include "App.h"
#include "Cli.h"
#include "imgui.h"
#include "imgui_impl_opengl3.h"
#include "imgui_impl_sdl2.h"
#include "implot.h"
#include <SDL.h>
#include <SDL_opengl.h>
#include <cstdio>
#include <string>
#include <sys/stat.h>
#include <unistd.h>
namespace {
/** Directory holding the running binary, or "." if it cannot be determined. */
std::string exeDir() {
char buf[4096];
const ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (n <= 0) {
return ".";
}
buf[n] = '\0';
std::string p(buf);
const size_t slash = p.rfind('/');
return (slash == std::string::npos) ? std::string(".") : p.substr(0, slash);
}
bool fileExists(const std::string& p) {
struct stat st;
return stat(p.c_str(), &st) == 0;
}
/** First existing of: next to the binary, the build tree, the source tree. */
std::string findFont(const char* name) {
const std::string dir = exeDir();
const std::string candidates[] = {
dir + "/resources/fonts/" + name,
dir + "/../share/udpscope/fonts/" + name,
std::string(APP_RESOURCE_DIR) + "/fonts/" + name,
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
if (fileExists(candidates[i])) {
return candidates[i];
}
}
return std::string();
}
void applyStyle() {
ImGui::StyleColorsDark();
ImGuiStyle& s = ImGui::GetStyle();
s.WindowRounding = 4.0f;
s.FrameRounding = 4.0f;
s.GrabRounding = 4.0f;
s.ScrollbarRounding = 4.0f;
s.WindowBorderSize = 1.0f;
/* Catppuccin Mocha, matching the StreamHub ImGui client. */
ImVec4* c = s.Colors;
c[ImGuiCol_WindowBg] = ImVec4(0.12f, 0.12f, 0.18f, 1.00f);
c[ImGuiCol_ChildBg] = ImVec4(0.14f, 0.14f, 0.20f, 1.00f);
c[ImGuiCol_PopupBg] = ImVec4(0.10f, 0.10f, 0.15f, 0.98f);
c[ImGuiCol_Border] = ImVec4(0.27f, 0.28f, 0.35f, 1.00f);
c[ImGuiCol_FrameBg] = ImVec4(0.19f, 0.20f, 0.27f, 1.00f);
c[ImGuiCol_FrameBgHovered] = ImVec4(0.24f, 0.25f, 0.33f, 1.00f);
c[ImGuiCol_TitleBgActive] = ImVec4(0.17f, 0.18f, 0.25f, 1.00f);
c[ImGuiCol_MenuBarBg] = ImVec4(0.15f, 0.15f, 0.22f, 1.00f);
c[ImGuiCol_Header] = ImVec4(0.24f, 0.25f, 0.33f, 1.00f);
c[ImGuiCol_Button] = ImVec4(0.22f, 0.23f, 0.31f, 1.00f);
c[ImGuiCol_ButtonHovered] = ImVec4(0.29f, 0.31f, 0.41f, 1.00f);
c[ImGuiCol_Text] = ImVec4(0.80f, 0.84f, 0.96f, 1.00f);
c[ImGuiCol_TextDisabled] = ImVec4(0.43f, 0.45f, 0.55f, 1.00f);
}
} /* namespace */
int main(int argc, char** argv) {
udpscope::CliOptions opt;
std::string err;
const udpscope::CliResult r = udpscope::ParseCli(argc, argv, opt, err);
if (r == udpscope::CliResult::Help) {
std::printf("%s", udpscope::CliUsage().c_str());
return 0;
}
if (r == udpscope::CliResult::Error) {
std::fprintf(stderr, "%s\n\n%s", err.c_str(), udpscope::CliUsage().c_str());
return 2;
}
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
std::fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
return 1;
}
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_Window* win = SDL_CreateWindow(
"UDPScope", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 1000,
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
if (win == nullptr) {
std::fprintf(stderr, "SDL_CreateWindow: %s\n", SDL_GetError());
SDL_Quit();
return 1;
}
SDL_GLContext gl = SDL_GL_CreateContext(win);
SDL_GL_MakeCurrent(win, gl);
SDL_GL_SetSwapInterval(1); /* vsync: the scope repaints at the refresh rate */
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImPlot::CreateContext();
ImGuiIO& io = ImGui::GetIO();
/* The layout lives in our own settings file (Task 14), not imgui.ini. */
io.IniFilename = nullptr;
applyStyle();
const std::string font = findFont("FiraSans-Regular.ttf");
if (!font.empty()) {
io.Fonts->AddFontFromFileTTF(font.c_str(), 16.0f);
}
ImGui_ImplSDL2_InitForOpenGL(win, gl);
ImGui_ImplOpenGL3_Init("#version 330");
{
udpscope::App app(opt);
bool done = false;
while (!done && !app.wantsQuit()) {
SDL_Event e;
while (SDL_PollEvent(&e) != 0) {
ImGui_ImplSDL2_ProcessEvent(&e);
if (e.type == SDL_QUIT) {
done = true;
}
if (e.type == SDL_WINDOWEVENT &&
e.window.event == SDL_WINDOWEVENT_CLOSE &&
e.window.windowID == SDL_GetWindowID(win)) {
done = true;
}
}
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame();
ImGui::NewFrame();
app.draw();
ImGui::Render();
int w = 0, h = 0;
SDL_GetWindowSize(win, &w, &h);
glViewport(0, 0, w, h);
glClearColor(0.09f, 0.09f, 0.13f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(win);
}
} /* App destroyed here, stopping the receiver before SDL shuts down. */
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImPlot::DestroyContext();
ImGui::DestroyContext();
SDL_GL_DeleteContext(gl);
SDL_DestroyWindow(win);
SDL_Quit();
return 0;
}
- Step 8: Update CMake for the app sources
Replace the application block in Client/udpscope/CMakeLists.txt — the
if(EXISTS main.cpp) guard from Task 1 has done its job and goes away:
# ── Application ───────────────────────────────────────────────────────────────
set(APP_SOURCES
main.cpp
App.cpp
SignalList.cpp
)
add_executable(UDPScope ${APP_SOURCES})
target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL)
target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}")
if(HAVE_FONT_AWESOME)
target_include_directories(UDPScope PRIVATE ${FONT_DIR})
target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME)
endif()
target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter)
install(TARGETS UDPScope DESTINATION bin)
install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope)
- Step 9: Build and run against a real streamer
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–9.
# terminal 1
source env.sh && "${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
-l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
-s Running -m StateMachine:START
# terminal 2
./Client/udpscope/build/UDPScope --host 127.0.0.1 --port 44501
Expected: a window opens, the badge reads CONNECTED, the signal list fills
with the demo signals, hovering one shows a plausible rate and buffered span,
and the status bar counters climb with gaps 0. Ctrl+Q closes it.
./Client/udpscope/build/UDPScope --help # prints usage, exits 0
./Client/udpscope/build/UDPScope -host 1.2.3.4 # rejects, exits 2
- Step 10: Commit
git add Client/udpscope/Cli.h Client/udpscope/Cli.cpp Client/udpscope/App.h \
Client/udpscope/App.cpp Client/udpscope/SignalList.cpp \
Client/udpscope/main.cpp \
Client/udpscope/tests/CliTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): CLI, SDL2/ImGui bootstrap and application shell"
Task 10: Trace fetching and the first real plot
Split the plotting work in two: PlotData decides what points to draw and
is fully unit-tested against the store; PaneView is thin ImPlot glue that
draws them. This task draws the tree's single root leaf across the whole plot
area and lets the user drag signals onto it. Splitting arrives in Task 11.
Files:
- Create:
Client/udpscope/PlotData.h - Create:
Client/udpscope/PlotData.cpp - Create:
Client/udpscope/PaneView.h - Create:
Client/udpscope/PaneView.cpp - Modify:
Client/udpscope/App.h,Client/udpscope/App.cpp - Modify:
Client/udpscope/CMakeLists.txt - Test:
Client/udpscope/tests/PlotDataTest.cpp
Interfaces:
- Consumes:
SignalStore,Capture,Profile(Task 6);MinMaxDecimate,Series,Color(Task 1);PaneNode,Assignment(Task 2). - Produces:
struct TraceData {
Series raw; // every sample in range — statistics use this
Series draw; // decimated to the plot budget
bool found = false;
};
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
Color PaletteColor(size_t index);
struct PaneContext {
SignalStore* store = nullptr;
const Capture* capture = nullptr; // nullptr => live
double x0 = 0.0, x1 = 1.0;
size_t maxPoints = 4000u;
};
class PaneView {
public:
void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
PaneContext& ctx);
};
Why raw is kept alongside draw: spec §8.4 requires min/max/pp/mean/RMS
to be computed from undecimated data. Computing them from the decimated
series would report the extremes of the drawn envelope but a mean and RMS
weighted by bucket rather than by sample. Task 14 reads TraceData::raw.
- Step 1: Write the failing tests
Create Client/udpscope/tests/PlotDataTest.cpp:
#include "PlotData.h"
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace udpscope;
namespace {
SignalMeta scalar(const std::string& name) {
SignalMeta m;
m.name = name;
m.typeCode = 8;
m.numRows = 1;
m.numCols = 1;
return m;
}
// Fills "a" with a 10 kHz ramp over [0, 1) s, with a one-sample spike at 0.5 s.
void fill(SignalStore& s) {
s.setSignals({scalar("a")});
std::vector<double> t(10000), v(10000);
for (size_t i = 0; i < t.size(); ++i) {
t[i] = static_cast<double>(i) * 1e-4;
v[i] = static_cast<double>(i);
}
v[5000] = 1.0e6;
s.setWindowSec(1.0);
s.maintain();
s.push("a", t.data(), v.data(), t.size());
}
} // namespace
TEST(PlotData, LiveFetchClipsToTheRequestedRange) {
SignalStore s;
fill(s);
TraceData d;
ASSERT_TRUE(FetchLiveTrace(s, "a", 0.2, 0.3, 4000, d));
EXPECT_TRUE(d.found);
EXPECT_NEAR(d.raw.t.front(), 0.2, 1e-9);
EXPECT_NEAR(d.raw.t.back(), 0.3, 1e-9);
EXPECT_EQ(d.raw.size(), 1001u);
}
TEST(PlotData, DrawSeriesRespectsTheBudgetAndRawDoesNot) {
SignalStore s;
fill(s);
TraceData d;
ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d));
EXPECT_EQ(d.raw.size(), 10000u) << "raw must stay undecimated for statistics";
EXPECT_LE(d.draw.size(), 500u);
EXPECT_GT(d.draw.size(), 2u);
}
// The reason for min/max decimation: the spike must still be on screen.
TEST(PlotData, DecimationKeepsTheSpike) {
SignalStore s;
fill(s);
TraceData d;
ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d));
EXPECT_DOUBLE_EQ(*std::max_element(d.draw.v.begin(), d.draw.v.end()), 1.0e6);
}
TEST(PlotData, AMissingSignalIsReportedNotFabricated) {
SignalStore s;
fill(s);
TraceData d;
EXPECT_FALSE(FetchLiveTrace(s, "ghost", 0.0, 1.0, 500, d));
EXPECT_FALSE(d.found);
EXPECT_TRUE(d.raw.empty());
EXPECT_TRUE(d.draw.empty());
}
TEST(PlotData, AnEmptyRangeYieldsAnEmptyTraceWithoutFailing) {
SignalStore s;
fill(s);
TraceData d;
EXPECT_TRUE(FetchLiveTrace(s, "a", 50.0, 51.0, 500, d));
EXPECT_TRUE(d.found);
EXPECT_TRUE(d.raw.empty());
EXPECT_TRUE(d.draw.empty());
}
TEST(PlotData, CaptureFetchReadsTheFrozenSnapshotNotTheRing) {
Capture cap;
cap.trigTime = 5.0;
cap.t0 = 4.9;
cap.t1 = 5.1;
cap.names.push_back("a");
cap.series.emplace_back();
for (int i = 0; i < 201; ++i) {
cap.series[0].t.push_back(4.9 + i * 1e-3);
cap.series[0].v.push_back(static_cast<double>(i));
}
TraceData d;
ASSERT_TRUE(FetchCaptureTrace(cap, "a", 4.95, 5.05, 4000, d));
EXPECT_EQ(d.raw.size(), 101u);
EXPECT_NEAR(d.raw.t.front(), 4.95, 1e-9);
TraceData miss;
EXPECT_FALSE(FetchCaptureTrace(cap, "b", 4.95, 5.05, 4000, miss));
}
TEST(PlotData, PaletteColoursAreDistinctAndWrap) {
const Color c0 = PaletteColor(0);
const Color c1 = PaletteColor(1);
EXPECT_FALSE(c0.r == c1.r && c0.g == c1.g && c0.b == c1.b);
const Color wrapped = PaletteColor(0 + 8);
EXPECT_FLOAT_EQ(wrapped.r, c0.r);
EXPECT_FLOAT_EQ(wrapped.a, 1.0f);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — PlotData.h: No such file or directory.
- Step 3: Write
PlotData.h
Create Client/udpscope/PlotData.h:
/**
* @file PlotData.h
* @brief Turns store or capture contents into a drawable trace.
*
* Framework-free so it can be tested without a GUI. Every fetch returns both
* the undecimated samples (for statistics) and the decimated ones (for the
* screen).
*/
#ifndef UDPSCOPE_PLOTDATA_H
#define UDPSCOPE_PLOTDATA_H
#include "SignalStore.h"
#include "Types.h"
#include <cstddef>
#include <string>
namespace udpscope {
struct TraceData {
Series raw; /**< every sample in [t0,t1]; statistics use this */
Series draw; /**< min/max-decimated to the plot budget */
bool found = false; /**< the signal exists, even if the range is empty */
};
/** @return false when the signal is not in the store. */
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
/** @return false when the signal is not in the capture. */
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
/** Deterministic 8-colour palette, wrapping on overflow. */
Color PaletteColor(size_t index);
} /* namespace udpscope */
#endif /* UDPSCOPE_PLOTDATA_H */
- Step 4: Write
PlotData.cpp
Create Client/udpscope/PlotData.cpp:
#include "PlotData.h"
#include "Decimate.h"
#include <algorithm>
namespace udpscope {
namespace {
/* Catppuccin Mocha accents, in the order the StreamHub clients use them. */
const Color kPalette[8] = {
{0.537f, 0.706f, 0.980f, 1.0f}, /* blue */
{0.980f, 0.702f, 0.529f, 1.0f}, /* peach */
{0.651f, 0.890f, 0.631f, 1.0f}, /* green */
{0.949f, 0.545f, 0.659f, 1.0f}, /* pink */
{0.976f, 0.886f, 0.686f, 1.0f}, /* yellow */
{0.796f, 0.651f, 0.969f, 1.0f}, /* mauve */
{0.584f, 0.890f, 0.839f, 1.0f}, /* teal */
{0.937f, 0.604f, 0.604f, 1.0f}, /* red */
};
void decimateInto(TraceData& d, size_t maxPoints) {
if (d.raw.empty()) {
d.draw.clear();
return;
}
MinMaxDecimate(d.raw.t.data(), d.raw.v.data(), d.raw.size(), maxPoints, d.draw);
}
} /* namespace */
Color PaletteColor(size_t index) {
return kPalette[index % 8u];
}
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out) {
out.raw.clear();
out.draw.clear();
out.found = false;
/* capacity() is 0 only for a name the store does not know. */
if (store.capacity(name) == 0u) {
return false;
}
out.found = true;
store.readRange(name, t0, t1, out.raw);
decimateInto(out, maxPoints);
return true;
}
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out) {
out.raw.clear();
out.draw.clear();
out.found = false;
for (size_t i = 0u; i < cap.names.size() && i < cap.series.size(); i++) {
if (cap.names[i] != name) {
continue;
}
out.found = true;
const Series& s = cap.series[i];
/* The capture is already sorted by time; a linear scan is fine because
it is bounded by the window, not by the ring. */
for (size_t k = 0u; k < s.t.size(); k++) {
if (s.t[k] >= t0 && s.t[k] <= t1) {
out.raw.t.push_back(s.t[k]);
out.raw.v.push_back(s.v[k]);
}
}
decimateInto(out, maxPoints);
return true;
}
return false;
}
} /* namespace udpscope */
Add PlotData.cpp to CORE_SOURCES.
- Step 5: Run the PlotData tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PlotData*'
Expected: PASS, 7 tests.
- Step 6: Write the pane view
Create Client/udpscope/PaneView.h:
/**
* @file PaneView.h
* @brief ImPlot rendering of one pane-tree leaf. GUI thread only.
*/
#ifndef UDPSCOPE_PANEVIEW_H
#define UDPSCOPE_PANEVIEW_H
#include "PaneTree.h"
#include "PlotData.h"
#include "SignalStore.h"
#include "imgui.h"
namespace udpscope {
/** Everything a leaf needs to draw itself, rebuilt each frame by App. */
struct PaneContext {
SignalStore* store = nullptr;
const Capture* capture = nullptr; /**< nullptr => draw live data */
double x0 = 0.0;
double x1 = 1.0;
size_t maxPoints = 4000u;
};
/** The ImGui drag-and-drop payload carrying a signal name from the list. */
extern const char* const kSignalPayload;
class PaneView {
public:
/** Draws one leaf, including its drop target and legend context menu. */
void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
PaneContext& ctx);
};
} /* namespace udpscope */
#endif /* UDPSCOPE_PANEVIEW_H */
Create Client/udpscope/PaneView.cpp:
#include "PaneView.h"
#include "implot.h"
#include <cstdio>
#include <cstring>
namespace udpscope {
const char* const kSignalPayload = "UDPSCOPE_SIG";
namespace {
ImVec4 toImVec4(const Color& c) { return ImVec4(c.r, c.g, c.b, c.a); }
/** Appends a signal to a pane, colouring it by its position in the pane. */
void assign(PaneNode& leaf, const char* name) {
for (size_t i = 0u; i < leaf.signals.size(); i++) {
if (leaf.signals[i].signalName == name) {
return; /* already shown here */
}
}
Assignment a;
a.signalName = name;
a.color = PaletteColor(leaf.signals.size());
leaf.signals.push_back(a);
}
} /* namespace */
void PaneView::drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
PaneContext& ctx) {
if (!ImPlot::BeginPlot(id, size, ImPlotFlags_NoTitle)) {
return;
}
ImPlot::SetupAxes("t [s]", nullptr);
/* The X range is owned by App so every pane shares it; Task 12 makes the
user's pan and zoom write back into it. */
ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1, ImPlotCond_Always);
TraceData d;
for (size_t i = 0u; i < leaf.signals.size(); i++) {
Assignment& a = leaf.signals[i];
const bool ok = (ctx.capture != nullptr)
? FetchCaptureTrace(*ctx.capture, a.signalName,
ctx.x0, ctx.x1, ctx.maxPoints, d)
: FetchLiveTrace(*ctx.store, a.signalName,
ctx.x0, ctx.x1, ctx.maxPoints, d);
if (!ok || d.draw.empty()) {
continue;
}
ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
ImPlot::PlotLine(a.signalName.c_str(), d.draw.t.data(), d.draw.v.data(),
static_cast<int>(d.draw.size()));
if (ImPlot::BeginLegendPopup(a.signalName.c_str())) {
ImGui::ColorEdit3("colour", &a.color.r);
ImGui::SliderFloat("width", &a.lineWidth, 0.5f, 4.0f, "%.1f px");
if (ImGui::Button("remove")) {
leaf.signals.erase(leaf.signals.begin() +
static_cast<long>(i));
ImGui::CloseCurrentPopup();
ImPlot::EndLegendPopup();
break;
}
ImPlot::EndLegendPopup();
}
}
/* Dropping anywhere on the plot assigns the signal to this pane. */
if (ImPlot::BeginDragDropTargetPlot()) {
const ImGuiPayload* p = ImGui::AcceptDragDropPayload(kSignalPayload);
if (p != nullptr && p->Data != nullptr) {
assign(leaf, static_cast<const char*>(p->Data));
}
ImPlot::EndDragDropTarget();
}
ImPlot::EndPlot();
}
} /* namespace udpscope */
- Step 7: Wire the view into App
In Client/udpscope/App.h, add #include "PaneView.h" and the members:
PaneView paneView_;
double xSpanSec_ = 1.0; /**< live window width, Task 12 makes it settable */
In Client/udpscope/SignalList.cpp — that is where Task 9 put
App::drawSignalList(), not App.cpp — make the signal list a drag source by
replacing the ImGui::Selectable(m.name.c_str()); line with:
ImGui::Selectable(m.name.c_str());
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
/* Payload is the name including its terminator, so the drop side
can use it as a C string directly. */
ImGui::SetDragDropPayload(kSignalPayload, m.name.c_str(),
m.name.size() + 1u);
ImGui::TextUnformatted(m.name.c_str());
ImGui::EndDragDropSource();
}
and replace drawPlotArea() with:
void App::drawPlotArea() {
PaneContext ctx;
ctx.store = &store_;
ctx.capture = nullptr;
ctx.maxPoints = opt_.maxPlotPoints;
/* Live: the window ends at the newest sample of any assigned signal. The
shared, user-controllable X range arrives in Task 12. */
double newest = 0.0;
bool any = false;
for (size_t i = 0u; i < sigs_.size(); i++) {
double o = 0.0, n = 0.0;
if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
newest = n;
any = true;
}
}
ctx.x1 = any ? newest : 1.0;
ctx.x0 = ctx.x1 - xSpanSec_;
PaneNode* root = tree_.root();
paneView_.drawLeaf(*root, "##pane0", ImGui::GetContentRegionAvail(), ctx);
}
- Step 8: Update CMake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
Cli.cpp
PlotData.cpp
)
set(APP_SOURCES
main.cpp
App.cpp
SignalList.cpp
PaneView.cpp
)
SignalList.cpp stays in the list — it holds App::drawSignalList() and
dropping it is a link error, not a warning.
PaneView.cpp needs ImGui headers, so it belongs to the executable, not the
core library — that is what keeps udpscope_tests free of a GUI dependency.
- Step 9: Build and check it plots
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–10.
Run it against the demo streamer as in Task 9, then drag Sine1 from the
list onto the plot. Expected: a 1 Hz sine scrolling right to left, smooth
(not a staircase), with the legend entry right-clickable for colour, width
and remove. Drag a second signal on and both draw with different colours.
- Step 10: Commit
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/tests/PlotDataTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trace fetching, min/max decimation and the first live plot"
Task 11: Splittable pane grid
Draw the whole tree instead of just the root leaf: every leaf gets its rect
from PaneTree::layout(), hovering one reveals four inset split handles and
a close ✕, and the shared borders drag to re-proportion the split.
Files:
- Modify:
Client/udpscope/PaneTree.h,Client/udpscope/PaneTree.cpp(deferred command application) - Modify:
Client/udpscope/PaneView.h,Client/udpscope/PaneView.cpp(tree drawing and interaction) - Modify:
Client/udpscope/App.cpp - Test:
Client/udpscope/tests/PaneCommandTest.cpp
Interfaces:
- Consumes:
PaneTree,PaneNode,Rect,Orient,Handle,kMinPaneSize,kSplitterGrab,kHandleSize(Task 2);PaneView,PaneContext(Task 10). - Produces:
// PaneTree.h
struct PaneCommand {
enum class Kind { None, Split, Close, SetRatio };
Kind kind = Kind::None;
PaneNode* target = nullptr;
Orient orient = Orient::Columns; // Split only
double ratio = 0.5; // SetRatio only
};
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
// PaneView.h
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);
Why commands are deferred: layout() hands out raw PaneNode*. Splitting
or closing during the walk re-parents nodes and invalidates those pointers
mid-frame. Collect at most one command per frame and apply it after the walk.
- Step 1: Write the failing tests
Create Client/udpscope/tests/PaneCommandTest.cpp:
#include "PaneTree.h"
#include <gtest/gtest.h>
using namespace udpscope;
TEST(PaneCommand, NoneLeavesTheTreeAlone) {
PaneTree tree;
PaneCommand cmd;
ApplyPaneCommand(tree, cmd);
EXPECT_EQ(tree.leafCount(), 1u);
}
TEST(PaneCommand, SplitAddsALeaf) {
PaneTree tree;
PaneCommand cmd;
cmd.kind = PaneCommand::Kind::Split;
cmd.target = tree.root();
cmd.orient = Orient::Rows;
ApplyPaneCommand(tree, cmd);
EXPECT_EQ(tree.leafCount(), 2u);
EXPECT_FALSE(tree.root()->leaf);
EXPECT_EQ(tree.root()->orient, Orient::Rows);
}
TEST(PaneCommand, CloseRemovesALeaf) {
PaneTree tree;
PaneCommand split;
split.kind = PaneCommand::Kind::Split;
split.target = tree.root();
ApplyPaneCommand(tree, split);
ASSERT_EQ(tree.leafCount(), 2u);
PaneCommand close;
close.kind = PaneCommand::Kind::Close;
close.target = tree.root()->a.get();
ApplyPaneCommand(tree, close);
EXPECT_EQ(tree.leafCount(), 1u);
}
// Closing the only pane would leave nothing to draw and no way to get a pane
// back, so it is refused.
TEST(PaneCommand, ClosingTheLastLeafIsRefused) {
PaneTree tree;
PaneNode* only = tree.root();
PaneCommand close;
close.kind = PaneCommand::Kind::Close;
close.target = only;
ApplyPaneCommand(tree, close);
EXPECT_EQ(tree.leafCount(), 1u);
EXPECT_TRUE(tree.root()->leaf);
}
TEST(PaneCommand, SetRatioClampsIntoTheLegalRange) {
PaneTree tree;
PaneCommand split;
split.kind = PaneCommand::Kind::Split;
split.target = tree.root();
ApplyPaneCommand(tree, split);
PaneCommand r;
r.kind = PaneCommand::Kind::SetRatio;
r.target = tree.root();
r.ratio = 5.0;
ApplyPaneCommand(tree, r);
EXPECT_LE(tree.root()->ratio, 1.0);
EXPECT_GT(tree.root()->ratio, 0.0);
}
TEST(PaneCommand, ANullTargetIsIgnored) {
PaneTree tree;
PaneCommand cmd;
cmd.kind = PaneCommand::Kind::Split;
cmd.target = nullptr;
ApplyPaneCommand(tree, cmd);
EXPECT_EQ(tree.leafCount(), 1u);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — no type named 'PaneCommand' in namespace 'udpscope'.
- Step 3: Add
PaneCommandtoPaneTree.h
Append inside namespace udpscope, after the PaneTree class:
/**
* A pending mutation of the tree.
*
* layout() hands out raw PaneNode*, which splitting or closing invalidates.
* The view therefore records at most one command per frame and applies it
* after the layout walk has finished.
*/
struct PaneCommand {
enum class Kind { None, Split, Close, SetRatio };
Kind kind = Kind::None;
PaneNode* target = nullptr;
Orient orient = Orient::Columns;
double ratio = 0.5;
};
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
- Step 4: Implement it in
PaneTree.cpp
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd) {
if (cmd.kind == PaneCommand::Kind::None || cmd.target == nullptr) {
return;
}
switch (cmd.kind) {
case PaneCommand::Kind::Split:
tree.splitLeaf(cmd.target, cmd.orient);
break;
case PaneCommand::Kind::Close:
/* Refuse the last one: an empty tree has nothing to draw and no way
back. */
if (tree.leafCount() > 1u) {
tree.closeLeaf(cmd.target);
}
break;
case PaneCommand::Kind::SetRatio:
tree.setRatio(cmd.target, cmd.ratio); /* clamps internally */
break;
case PaneCommand::Kind::None:
break;
}
}
- Step 5: Run the command tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneCommand*:PaneTree*'
Expected: PASS, 18 tests (12 from Task 2, 6 new).
- Step 6: Draw the tree
In Client/udpscope/PaneView.h, add to the class:
void drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);
private:
void drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd);
PaneCommand pending_;
PaneNode* dragSplitter_ = nullptr;
In Client/udpscope/PaneView.cpp, add:
namespace {
/* Panes are drawn inset so the shared border stays free for the splitter. */
const float kInset = 3.0f;
ImVec2 topLeft(const Rect& r) { return ImVec2((float) r.x, (float) r.y); }
ImVec2 sizeOf(const Rect& r) { return ImVec2((float) r.w, (float) r.h); }
} /* namespace */
void PaneView::drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd) {
ImDrawList* dl = ImGui::GetWindowDrawList();
const float h = (float) kHandleSize;
const ImU32 bg = IM_COL32(60, 62, 84, 220);
const ImU32 fg = IM_COL32(205, 214, 244, 255);
struct Spot { Handle which; ImVec2 pos; const char* glyph; };
const Spot spots[5] = {
{Handle::Left, ImVec2((float) (r.x + kHandleSize),
(float) (r.y + r.h * 0.5)), "|"},
{Handle::Right, ImVec2((float) (r.x + r.w - kHandleSize),
(float) (r.y + r.h * 0.5)), "|"},
{Handle::Top, ImVec2((float) (r.x + r.w * 0.5),
(float) (r.y + kHandleSize)), "-"},
{Handle::Bottom, ImVec2((float) (r.x + r.w * 0.5),
(float) (r.y + r.h - kHandleSize)), "-"},
{Handle::Close, ImVec2((float) (r.x + r.w - kHandleSize),
(float) (r.y + kHandleSize)), "x"},
};
for (int i = 0; i < 5; i++) {
const ImVec2 c = spots[i].pos;
ImGui::SetCursorScreenPos(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f));
ImGui::PushID(i);
/* Submitted after the plot, so it wins the hit test over it. */
const bool clicked = ImGui::InvisibleButton("##handle", ImVec2(h, h));
const bool hovered = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f),
ImVec2(c.x + h * 0.5f, c.y + h * 0.5f),
hovered ? IM_COL32(88, 91, 112, 255) : bg, 3.0f);
dl->AddText(ImVec2(c.x - 3.0f, c.y - 7.0f), fg, spots[i].glyph);
if (!clicked) {
continue;
}
switch (spots[i].which) {
case Handle::Left:
case Handle::Right:
cmd.kind = PaneCommand::Kind::Split;
cmd.target = &leaf;
cmd.orient = Orient::Columns;
break;
case Handle::Top:
case Handle::Bottom:
cmd.kind = PaneCommand::Kind::Split;
cmd.target = &leaf;
cmd.orient = Orient::Rows;
break;
case Handle::Close:
cmd.kind = PaneCommand::Kind::Close;
cmd.target = &leaf;
break;
case Handle::None:
break;
}
}
}
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx) {
std::vector<PaneTree::Placed> placed;
std::vector<PaneTree::Splitter> splitters;
tree.layout(area, placed, splitters);
PaneCommand cmd;
for (size_t i = 0u; i < placed.size(); i++) {
Rect r = placed[i].rect;
r.x += kInset;
r.y += kInset;
r.w -= 2.0 * kInset;
r.h -= 2.0 * kInset;
if (r.w <= 1.0 || r.h <= 1.0) {
continue;
}
char id[32];
std::snprintf(id, sizeof(id), "##pane%zu", i);
ImGui::SetCursorScreenPos(topLeft(r));
drawLeaf(*placed[i].leaf, id, sizeOf(r), ctx);
const ImVec2 m = ImGui::GetIO().MousePos;
if (r.contains(m.x, m.y)) {
drawHandles(*placed[i].leaf, r, cmd);
}
}
/* Splitter drags. The grab zone is the gap the inset left behind. */
for (size_t i = 0u; i < splitters.size(); i++) {
const Rect& s = splitters[i].rect;
ImGui::SetCursorScreenPos(topLeft(s));
ImGui::PushID(static_cast<int>(1000 + i));
ImGui::InvisibleButton("##split", sizeOf(s));
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
ImGui::PopID();
if (hovered || active) {
ImGui::SetMouseCursor(splitters[i].orient == Orient::Columns
? ImGuiMouseCursor_ResizeEW
: ImGuiMouseCursor_ResizeNS);
}
if (!active) {
continue;
}
/* Convert the drag into a ratio on the parent's own extent, so the
pointer stays glued to the border regardless of nesting depth. The
Splitter struct does not carry the parent's size, so recover it by
summing the child rects that this splitter separates. */
const PaneNode* node = splitters[i].node;
const ImVec2 d = ImGui::GetIO().MouseDelta;
const double delta = (splitters[i].orient == Orient::Columns) ? d.x : d.y;
double parentExtent = 0.0;
for (size_t k = 0u; k < placed.size(); k++) {
const Rect& pr = placed[k].rect;
if (splitters[i].orient == Orient::Columns) {
if (pr.y <= s.y + 1.0 && pr.y + pr.h >= s.y + s.h - 1.0) {
parentExtent += pr.w;
}
} else {
if (pr.x <= s.x + 1.0 && pr.x + pr.w >= s.x + s.w - 1.0) {
parentExtent += pr.h;
}
}
}
if (parentExtent < kMinPaneSize) {
continue;
}
cmd.kind = PaneCommand::Kind::SetRatio;
cmd.target = const_cast<PaneNode*>(node);
cmd.ratio = node->ratio + delta / parentExtent;
}
ApplyPaneCommand(tree, cmd);
}
drawTree() needs #include <cstdio> for snprintf and <vector>; both are
already pulled in by PaneView.cpp's existing includes.
- Step 7: Call it from App
Replace the last two lines of App::drawPlotArea():
const ImVec2 origin = ImGui::GetCursorScreenPos();
const ImVec2 avail = ImGui::GetContentRegionAvail();
Rect area;
area.x = origin.x;
area.y = origin.y;
area.w = avail.x;
area.h = avail.y;
paneView_.drawTree(tree_, area, ctx);
- Step 8: Build and check the interaction by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–11.
Run against the demo streamer and walk this checklist:
- Hover a pane: five handles appear (four edge midpoints, ✕ top-right).
- Click the right handle: two side-by-side panes, the new one empty.
- Drag a signal onto the new pane: it plots there and not in the first.
- Click the bottom handle of the right pane: it splits into two rows.
- Drag the border between the columns: both resize, the pointer stays on the border, and neither pane goes below roughly 80 px.
- Click ✕ on the bottom-right pane: it closes and its sibling takes the space.
- Close panes until one is left, then click its ✕: nothing happens.
- Step 9: Commit
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.cpp Client/udpscope/tests/PaneCommandTest.cpp
git commit -m "feat(udpscope): splittable pane grid with inset handles and draggable splitters"
Task 12: Shared X axis and per-trace vertical scale
Two pieces of scope behaviour that are pure arithmetic and therefore fully testable: the X axis that every pane shares and that follows the newest sample until the user pans, and the per-trace vertical scale in the three modes the spec calls for.
Every pane's Y axis is a fixed ±4 divisions, exactly like a bench scope, and
each trace is mapped into division space by its own volts-per-division and
offset. Auto and Range compute those two numbers; Manual takes them from
the user. This is what makes traces with different units share a pane without
either being squashed.
Files:
- Create:
Client/udpscope/Axes.h - Create:
Client/udpscope/Axes.cpp - Modify:
Client/udpscope/App.h,Client/udpscope/App.cpp - Modify:
Client/udpscope/PaneView.cpp - Modify:
Client/udpscope/CMakeLists.txt - Test:
Client/udpscope/tests/AxesTest.cpp
Interfaces:
- Consumes:
VScale,VMode(Task 2);SignalMeta,Series(Task 1). - Produces:
constexpr double kDivisionsY = 8.0; // full pane height, ±4
constexpr double kUsableDivsY = 6.0; // data fills 6 of the 8
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
double ToDivisions(double value, const VScale& vs);
class XAxisController {
public:
void setSpan(double sec);
double span() const;
void setLive(bool live);
bool live() const;
void followNewest(double newest);
void userRange(double x0, double x1);
double x0() const;
double x1() const;
};
- Step 1: Write the failing tests
Create Client/udpscope/tests/AxesTest.cpp:
#include "Axes.h"
#include <gtest/gtest.h>
#include <cmath>
using namespace udpscope;
namespace {
SignalMeta meta(double lo, double hi) {
SignalMeta m;
m.name = "a";
m.typeCode = 8;
m.rangeMin = lo;
m.rangeMax = hi;
return m;
}
Series ramp(double lo, double hi, size_t n) {
Series s;
for (size_t i = 0; i < n; ++i) {
const double f = static_cast<double>(i) / static_cast<double>(n - 1);
s.t.push_back(static_cast<double>(i));
s.v.push_back(lo + f * (hi - lo));
}
return s;
}
} // namespace
TEST(VScale, AutoCentresTheDataAndFillsTheUsableDivisions) {
VScale vs;
vs.mode = VMode::Auto;
const Series s = ramp(1.0, 3.0, 100);
ComputeVScale(meta(-10.0, 10.0), s, vs);
EXPECT_NEAR(vs.offset, 2.0, 1e-9) << "midpoint should be screen centre";
EXPECT_NEAR(vs.div, 2.0 / kUsableDivsY, 1e-9);
EXPECT_NEAR(ToDivisions(3.0, vs), kUsableDivsY / 2.0, 1e-9);
EXPECT_NEAR(ToDivisions(1.0, vs), -kUsableDivsY / 2.0, 1e-9);
EXPECT_NEAR(ToDivisions(2.0, vs), 0.0, 1e-9);
}
// range_min/range_max are already in the CONFIG packet and are the physically
// meaningful full scale, so Range must not peek at the data.
TEST(VScale, RangeUsesTheConfigFullScaleNotTheData) {
VScale vs;
vs.mode = VMode::Range;
const Series s = ramp(1.0, 1.001, 100);
ComputeVScale(meta(-10.0, 10.0), s, vs);
EXPECT_NEAR(vs.offset, 0.0, 1e-9);
EXPECT_NEAR(vs.div, 20.0 / kUsableDivsY, 1e-9);
EXPECT_NEAR(ToDivisions(10.0, vs), kUsableDivsY / 2.0, 1e-9);
}
TEST(VScale, ManualIsLeftExactlyAsTheUserSetIt) {
VScale vs;
vs.mode = VMode::Manual;
vs.div = 0.25;
vs.offset = 1.5;
ComputeVScale(meta(-10.0, 10.0), ramp(0.0, 100.0, 10), vs);
EXPECT_DOUBLE_EQ(vs.div, 0.25);
EXPECT_DOUBLE_EQ(vs.offset, 1.5);
EXPECT_DOUBLE_EQ(ToDivisions(1.75, vs), 1.0);
}
// A flat signal has zero peak-to-peak; dividing by it would put the trace at
// infinity instead of on the centre line.
TEST(VScale, AConstantSignalGetsAUsableScale) {
VScale vs;
vs.mode = VMode::Auto;
Series s;
for (int i = 0; i < 10; ++i) { s.t.push_back(i); s.v.push_back(7.0); }
ComputeVScale(meta(0.0, 0.0), s, vs);
EXPECT_GT(vs.div, 0.0);
EXPECT_TRUE(std::isfinite(ToDivisions(7.0, vs)));
EXPECT_NEAR(ToDivisions(7.0, vs), 0.0, 1e-9);
}
TEST(VScale, AutoOnAnEmptySeriesLeavesTheScaleUsable) {
VScale vs;
vs.mode = VMode::Auto;
const Series empty;
ComputeVScale(meta(0.0, 0.0), empty, vs);
EXPECT_GT(vs.div, 0.0);
}
TEST(XAxis, LiveFollowsTheNewestSample) {
XAxisController x;
x.setSpan(2.0);
x.followNewest(100.0);
EXPECT_DOUBLE_EQ(x.x1(), 100.0);
EXPECT_DOUBLE_EQ(x.x0(), 98.0);
x.followNewest(101.0);
EXPECT_DOUBLE_EQ(x.x1(), 101.0);
}
TEST(XAxis, AUserRangeDetachesFromLive) {
XAxisController x;
x.setSpan(2.0);
x.followNewest(100.0);
x.userRange(10.0, 12.5);
EXPECT_FALSE(x.live());
EXPECT_DOUBLE_EQ(x.x0(), 10.0);
EXPECT_DOUBLE_EQ(x.span(), 2.5) << "a zoom must redefine the span";
x.followNewest(200.0);
EXPECT_DOUBLE_EQ(x.x0(), 10.0) << "detached axis must not be dragged along";
}
TEST(XAxis, ReattachingSnapsBackToTheNewestSample) {
XAxisController x;
x.setSpan(2.0);
x.userRange(10.0, 12.0);
ASSERT_FALSE(x.live());
x.setLive(true);
x.followNewest(300.0);
EXPECT_TRUE(x.live());
EXPECT_DOUBLE_EQ(x.x1(), 300.0);
EXPECT_DOUBLE_EQ(x.x0(), 298.0);
}
TEST(XAxis, ChangingTheSpanKeepsTheRightEdgePinned) {
XAxisController x;
x.setSpan(2.0);
x.followNewest(100.0);
x.setSpan(0.5);
EXPECT_DOUBLE_EQ(x.x1(), 100.0);
EXPECT_DOUBLE_EQ(x.x0(), 99.5);
}
TEST(XAxis, ADegenerateRangeIsRejected) {
XAxisController x;
x.setSpan(2.0);
x.userRange(5.0, 5.0);
EXPECT_GT(x.span(), 0.0);
x.setSpan(0.0);
EXPECT_GT(x.span(), 0.0);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Axes.h: No such file or directory.
- Step 3: Write
Axes.h
Create Client/udpscope/Axes.h:
/**
* @file Axes.h
* @brief Shared X axis behaviour and per-trace vertical scaling.
*
* Panes always show ±4 divisions vertically, as a bench scope does. Each trace
* carries its own volts-per-division and offset, so traces with different units
* can share a pane without one flattening the other.
*/
#ifndef UDPSCOPE_AXES_H
#define UDPSCOPE_AXES_H
#include "PaneTree.h"
#include "Types.h"
namespace udpscope {
/** Full pane height in divisions. */
constexpr double kDivisionsY = 8.0;
/** Divisions the data is scaled to fill, leaving one at the top and bottom. */
constexpr double kUsableDivsY = 6.0;
/**
* Fill vs.div and vs.offset for Auto and Range. Manual is left untouched.
* Never leaves div at zero, whatever the data does.
*/
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
/** Map a value into division space: 0 is the centre line. */
double ToDivisions(double value, const VScale& vs);
/**
* The one X range every pane shares.
*
* Live mode pins the right edge to the newest sample. Any pan or zoom detaches
* it — silently re-attaching would fight the user every frame — and the Live
* button re-attaches.
*/
class XAxisController {
public:
void setSpan(double sec);
double span() const { return span_; }
void setLive(bool live) { live_ = live; }
bool live() const { return live_; }
/** Called once a frame while live. Ignored when detached. */
void followNewest(double newest);
/** Records a user pan or zoom and detaches. */
void userRange(double x0, double x1);
double x0() const { return x1_ - span_; }
double x1() const { return x1_; }
private:
double span_ = 1.0;
double x1_ = 1.0;
bool live_ = true;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_AXES_H */
- Step 4: Write
Axes.cpp
Create Client/udpscope/Axes.cpp:
#include "Axes.h"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/** Turn a full-scale span into a per-division value, never zero. */
double divFromSpan(double span) {
if (!(span > 0.0) || !std::isfinite(span)) {
return 1.0;
}
return span / kUsableDivsY;
}
} /* namespace */
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs) {
if (vs.mode == VMode::Manual) {
if (!(vs.div > 0.0) || !std::isfinite(vs.div)) {
vs.div = 1.0;
}
return;
}
if (vs.mode == VMode::Range) {
/* The CONFIG full scale, whatever the data happens to be doing. */
vs.offset = 0.5 * (meta.rangeMin + meta.rangeMax);
vs.div = divFromSpan(meta.rangeMax - meta.rangeMin);
return;
}
/* Auto: fit the samples actually on screen. */
if (raw.empty()) {
vs.offset = 0.0;
vs.div = 1.0;
return;
}
double lo = raw.v[0];
double hi = raw.v[0];
for (size_t i = 1u; i < raw.v.size(); i++) {
lo = std::min(lo, raw.v[i]);
hi = std::max(hi, raw.v[i]);
}
vs.offset = 0.5 * (lo + hi);
vs.div = divFromSpan(hi - lo); /* a flat trace lands on the centre */
}
double ToDivisions(double value, const VScale& vs) {
const double d = (vs.div > 0.0 && std::isfinite(vs.div)) ? vs.div : 1.0;
return (value - vs.offset) / d;
}
void XAxisController::setSpan(double sec) {
if (sec > 0.0 && std::isfinite(sec)) {
span_ = sec; /* x1_ is unchanged, so the right edge stays pinned */
}
}
void XAxisController::followNewest(double newest) {
if (live_ && std::isfinite(newest)) {
x1_ = newest;
}
}
void XAxisController::userRange(double x0, double x1) {
if (!(x1 > x0) || !std::isfinite(x0) || !std::isfinite(x1)) {
return;
}
span_ = x1 - x0;
x1_ = x1;
live_ = false;
}
} /* namespace udpscope */
Add Axes.cpp to CORE_SOURCES.
- Step 5: Run the axis tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='VScale*:XAxis*'
Expected: PASS, 10 tests.
- Step 6: Draw traces in division space
In Client/udpscope/PaneView.cpp, add #include "Axes.h" and replace the
plotting body of drawLeaf()'s loop (between the FetchLiveTrace call and
BeginLegendPopup) with:
if (!ok || d.draw.empty()) {
continue;
}
ComputeVScale(ctx.metaFor(a.signalName), d.raw, a.vs);
/* Reuse one scratch buffer per pane rather than allocating per trace
at 60 Hz. */
divScratch_.resize(d.draw.size());
for (size_t k = 0u; k < d.draw.size(); k++) {
divScratch_[k] = ToDivisions(d.draw.v[k], a.vs);
}
char label[128];
std::snprintf(label, sizeof(label), "%s %.4g %s/div", a.signalName.c_str(),
a.vs.div, ctx.metaFor(a.signalName).unit.empty()
? "u" : ctx.metaFor(a.signalName).unit.c_str());
ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
ImPlot::PlotLine(label, d.draw.t.data(), divScratch_.data(),
static_cast<int>(d.draw.size()));
The legend popup key must change with the label, so replace
ImPlot::BeginLegendPopup(a.signalName.c_str()) with
ImPlot::BeginLegendPopup(label), and add the vertical-scale controls to it:
const char* modes[] = {"auto", "range", "manual"};
int mode = static_cast<int>(a.vs.mode);
if (ImGui::Combo("v-scale", &mode, modes, 3)) {
a.vs.mode = static_cast<VMode>(mode);
}
if (a.vs.mode == VMode::Manual) {
ImGui::InputDouble("per div", &a.vs.div, 0.0, 0.0, "%.6g");
ImGui::InputDouble("offset", &a.vs.offset, 0.0, 0.0, "%.6g");
}
Add to PaneView's private section:
std::vector<double> divScratch_;
and to PaneContext:
const std::vector<SignalMeta>* metas = nullptr;
/** Metadata for a signal, or a default-constructed one if it is gone. */
const SignalMeta& metaFor(const std::string& name) const;
implemented in PaneView.cpp:
const SignalMeta& PaneContext::metaFor(const std::string& name) const {
static const SignalMeta kUnknown;
if (metas != nullptr) {
for (size_t i = 0u; i < metas->size(); i++) {
if ((*metas)[i].name == name) {
return (*metas)[i];
}
}
}
return kUnknown;
}
Finally pin the Y axis and let ImPlot report the user's X range. Replace the
SetupAxes/SetupAxisLimits pair with:
ImPlot::SetupAxes("t [s]", "div");
ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1,
ctx.xLive ? ImPlotCond_Always : ImPlotCond_Once);
ImPlot::SetupAxisLimits(ImAxis_Y1, -kDivisionsY * 0.5, kDivisionsY * 0.5,
ImPlotCond_Always);
and just before ImPlot::EndPlot():
/* A pan or zoom in any pane redefines the shared range for all of them. */
if (ImPlot::IsPlotHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
const ImPlotRect lim = ImPlot::GetPlotLimits();
ctx.userX0 = lim.X.Min;
ctx.userX1 = lim.X.Max;
ctx.userChanged = true;
}
if (ImPlot::IsPlotHovered() && ImGui::GetIO().MouseWheel != 0.0f) {
const ImPlotRect lim = ImPlot::GetPlotLimits();
ctx.userX0 = lim.X.Min;
ctx.userX1 = lim.X.Max;
ctx.userChanged = true;
}
with the three new PaneContext fields:
bool xLive = true;
double userX0 = 0.0, userX1 = 0.0;
bool userChanged = false;
- Step 7: Own the axis in App
In App.h, add #include "Axes.h" and replace double xSpanSec_ with:
XAxisController xaxis_;
In App.cpp, replace the range computation in drawPlotArea() with:
double newest = 0.0;
bool any = false;
for (size_t i = 0u; i < sigs_.size(); i++) {
double o = 0.0, n = 0.0;
if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
newest = n;
any = true;
}
}
if (any) {
xaxis_.followNewest(newest);
}
ctx.x0 = xaxis_.x0();
ctx.x1 = xaxis_.x1();
ctx.xLive = xaxis_.live();
ctx.metas = &sigs_;
and after paneView_.drawTree(...):
if (ctx.userChanged) {
xaxis_.userRange(ctx.userX0, ctx.userX1);
}
There is no View menu yet — Task 9 built only File and Help. Add one to
drawMenuBar(), between the File and Help blocks:
if (ImGui::BeginMenu("View")) {
bool live = xaxis_.live();
if (ImGui::MenuItem("Live", "L", &live)) {
xaxis_.setLive(live);
}
double span = xaxis_.span();
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputDouble("window [s]", &span, 0.0, 0.0, "%.4g")) {
xaxis_.setSpan(span);
}
ImGui::EndMenu();
}
- Step 8: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–12.
Against the demo streamer:
- Two panes, a signal in each: both scroll together.
- Drag left in one pane: both stop following and pan together; View → Live is now unchecked.
- Tick View → Live: both snap back to the newest sample.
- Set View → window to 0.05: both show 50 ms, right edge pinned.
- Right-click a legend entry, set v-scale to
range: the trace rescales to the CONFIG full scale and the label shows the new value per division. - Set it to
manualwith a small per-div: the trace clips off the top and bottom of its ±4 divisions, as a bench scope does.
- Step 9: Commit
git add Client/udpscope/Axes.h Client/udpscope/Axes.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp Client/udpscope/PaneView.h \
Client/udpscope/PaneView.cpp Client/udpscope/tests/AxesTest.cpp \
Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): shared X axis with live follow and per-trace division scaling"
Task 13: Trigger bar and capture display
Files:
- Create:
Client/udpscope/CaptureLatch.h - Create:
Client/udpscope/CaptureLatch.cpp - Create:
Client/udpscope/tests/CaptureLatchTest.cpp - Create:
Client/udpscope/TriggerBar.cpp - Modify:
Client/udpscope/App.h - Modify:
Client/udpscope/App.cpp - Modify:
Client/udpscope/CMakeLists.txt
Interfaces:
- Consumes:
SignalStore::captureSeq(),SignalStore::readCapture(),Capture(Task 6);Receiver::setTrigConfig/trigConfig/arm/disarm/rearm/trigStatusandTrigStatus(Task 7);TrigConfig,Edge,TrigMode,TrigState(Task 5);PaneContext::capture(Task 10);XAxisController(Task 12). - Produces:
class CaptureLatchwithpoll(const SignalStore&) -> bool,showing(),setFollow(bool),follow(),returnToLive(),capture() -> const Capture&,x0(),x1(),seen() -> uint64_t; andstd::string TrigBadge(const TrigStatus&). Task 16 exportsCaptureLatch::capture()to CSV.
Why a latch rather than reading the store directly in the draw call: the pane tree draws many leaves per frame and each one needs the same capture, so the copy out of the store must happen once per frame, not once per pane. The latch also owns the freeze decision — a user studying one capture must not have it swapped out from under them by the next trigger — and that decision is pure state machine, so it is unit-testable without a window.
- Step 1: Write the failing tests
Create Client/udpscope/tests/CaptureLatchTest.cpp:
#include "CaptureLatch.h"
#include <gtest/gtest.h>
#include <string>
using namespace udpscope;
namespace {
/* Publish a capture whose bounds identify it, so the tests can tell which
one the latch is holding. */
void publish(SignalStore& s, double t0, double t1) {
Capture c;
c.trigTime = 0.5 * (t0 + t1);
c.t0 = t0;
c.t1 = t1;
c.names.push_back("sig");
Series ser;
ser.t.push_back(t0);
ser.v.push_back(1.0);
c.series.push_back(ser);
s.publishCapture(std::move(c));
}
TrigStatus status(TrigState st, double fill) {
TrigStatus s;
s.state = st;
s.fill = fill;
return s;
}
} // namespace
TEST(CaptureLatch, FreshLatchShowsLive) {
CaptureLatch latch;
SignalStore store;
EXPECT_FALSE(latch.showing());
EXPECT_FALSE(latch.poll(store));
EXPECT_FALSE(latch.showing());
EXPECT_TRUE(latch.follow());
}
TEST(CaptureLatch, PollAdoptsTheFirstCapture) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
EXPECT_TRUE(latch.poll(store));
ASSERT_TRUE(latch.showing());
EXPECT_DOUBLE_EQ(latch.x0(), 1.0);
EXPECT_DOUBLE_EQ(latch.x1(), 2.0);
EXPECT_EQ(latch.capture().names.size(), 1u);
}
// The draw loop polls every frame; only a genuinely new capture is news.
TEST(CaptureLatch, PollIsIdempotentWithoutANewCapture) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
EXPECT_FALSE(latch.poll(store));
EXPECT_FALSE(latch.poll(store));
EXPECT_TRUE(latch.showing());
}
TEST(CaptureLatch, FollowingLatchAdoptsTheNewerCapture) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
publish(store, 5.0, 6.0);
EXPECT_TRUE(latch.poll(store));
EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}
// Studying a waveform must not be interrupted by the next trigger.
TEST(CaptureLatch, FrozenLatchKeepsTheDisplayedCapture) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
latch.setFollow(false);
publish(store, 5.0, 6.0);
EXPECT_FALSE(latch.poll(store));
EXPECT_DOUBLE_EQ(latch.x0(), 1.0);
}
// Un-freezing must not have to wait for yet another trigger: the capture that
// arrived while frozen is still the newest one, and it is adopted at once.
TEST(CaptureLatch, UnfreezingAdoptsTheCaptureThatArrivedWhileFrozen) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
latch.setFollow(false);
publish(store, 5.0, 6.0);
ASSERT_FALSE(latch.poll(store));
latch.setFollow(true);
EXPECT_TRUE(latch.poll(store));
EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}
TEST(CaptureLatch, ReturnToLiveDropsTheDisplayedCapture) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
latch.returnToLive();
EXPECT_FALSE(latch.showing());
EXPECT_FALSE(latch.poll(store)); // the same capture is not re-adopted
}
TEST(CaptureLatch, ReturnToLiveIsUndoneByTheNextTrigger) {
CaptureLatch latch;
SignalStore store;
publish(store, 1.0, 2.0);
ASSERT_TRUE(latch.poll(store));
latch.returnToLive();
publish(store, 5.0, 6.0);
EXPECT_TRUE(latch.poll(store));
EXPECT_TRUE(latch.showing());
EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}
TEST(TrigBadge, IdleReadsIdle) {
EXPECT_EQ(TrigBadge(status(TrigState::Idle, 0.0)), "IDLE");
}
TEST(TrigBadge, ArmedShowsTheFillPercentage) {
EXPECT_EQ(TrigBadge(status(TrigState::Armed, 0.625)), "ARMED 62%");
EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.0)), "ARMED 100%");
}
// fill is a ratio computed from live timestamps and can overshoot slightly.
TEST(TrigBadge, ArmedPercentageIsClamped) {
EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.4)), "ARMED 100%");
EXPECT_EQ(TrigBadge(status(TrigState::Armed, -0.2)), "ARMED 0%");
}
TEST(TrigBadge, CollectingAndHeldHaveTheirOwnLabels) {
EXPECT_EQ(TrigBadge(status(TrigState::Collecting, 1.0)), "TRIG'D");
EXPECT_EQ(TrigBadge(status(TrigState::Held, 1.0)), "HELD");
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — CaptureLatch.h: No such file or directory.
- Step 3: Write
CaptureLatch.h
Create Client/udpscope/CaptureLatch.h:
/**
* @file CaptureLatch.h
* @brief Decides, once per frame, whether the panes draw live data or a
* harvested trigger capture.
*/
#ifndef UDPSCOPE_CAPTURELATCH_H
#define UDPSCOPE_CAPTURELATCH_H
#include "Receiver.h"
#include "SignalStore.h"
#include <cstdint>
#include <string>
namespace udpscope {
/** Human-readable trigger state for the trigger bar badge. */
std::string TrigBadge(const TrigStatus& st);
/**
* Holds the capture the panes are drawing. The receiver thread publishes
* captures into the SignalStore; this pulls at most one copy per frame so
* that every pane in the tree draws the same waveform.
*/
class CaptureLatch {
public:
/**
* Adopts a newly published capture if there is one and the latch is
* following. Returns true only on the frame the capture changes, which
* is when the caller should re-range the X axis.
*/
bool poll(const SignalStore& store);
/** True when capture() is valid and the panes should draw it. */
bool showing() const { return showing_; }
/** The capture being displayed; only meaningful while showing(). */
const Capture& capture() const { return cap_; }
double x0() const { return cap_.t0; }
double x1() const { return cap_.t1; }
/** Sequence number of the capture last adopted; 0 before the first. */
uint64_t seen() const { return seen_; }
/** When false, new captures are ignored until following resumes. */
void setFollow(bool on) { follow_ = on; }
bool follow() const { return follow_; }
/** Drops the displayed capture and goes back to live data. */
void returnToLive() { showing_ = false; }
private:
Capture cap_;
uint64_t seen_ = 0u;
bool showing_ = false;
bool follow_ = true;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_CAPTURELATCH_H */
- Step 4: Write
CaptureLatch.cpp
Create Client/udpscope/CaptureLatch.cpp:
#include "CaptureLatch.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace udpscope {
std::string TrigBadge(const TrigStatus& st) {
switch (st.state) {
case TrigState::Armed: {
double f = st.fill;
if (!std::isfinite(f)) {
f = 0.0;
}
f = std::min(1.0, std::max(0.0, f));
char buf[32];
std::snprintf(buf, sizeof(buf), "ARMED %d%%",
static_cast<int>(f * 100.0));
return std::string(buf);
}
case TrigState::Collecting:
return "TRIG'D";
case TrigState::Held:
return "HELD";
case TrigState::Idle:
default:
return "IDLE";
}
}
bool CaptureLatch::poll(const SignalStore& store) {
if (!follow_) {
return false;
}
/* seen_ is only advanced when a capture is actually adopted, so a
capture published while frozen is picked up as soon as following
resumes. */
if (store.captureSeq() == seen_) {
return false;
}
Capture fresh;
if (!store.readCapture(fresh)) {
return false;
}
cap_ = std::move(fresh);
seen_ = cap_.seq;
showing_ = true;
return true;
}
} /* namespace udpscope */
Add CaptureLatch.cpp to CORE_SOURCES in Client/udpscope/CMakeLists.txt.
- Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='CaptureLatch*:TrigBadge*'
Expected: PASS, 12 tests.
- Step 6: Commit the latch
git add Client/udpscope/CaptureLatch.h Client/udpscope/CaptureLatch.cpp \
Client/udpscope/tests/CaptureLatchTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): capture latch and trigger state badge"
- Step 7: Add the trigger bar to App.h
In Client/udpscope/App.h, add the include and members:
#include "CaptureLatch.h"
Add to the private method list, next to drawMenuBar():
void drawTriggerBar();
/** Pushes the edited config into the receiver and re-ranges the panes. */
void applyTrigConfig();
and to the private data, after PaneTree tree_;:
CaptureLatch latch_;
TrigConfig trig_; /**< the bar's editable copy */
int trigSignal_ = -1; /**< index into sigs_, -1 = none */
- Step 8: Draw the trigger bar
The bar gets its own translation unit, as spec §3.4 lays out; the functions
are still App methods so they reach the receiver and the latch directly.
Create Client/udpscope/TriggerBar.cpp:
#include "App.h"
#include "imgui.h"
namespace udpscope {
void App::applyTrigConfig() {
rx_.setTrigConfig(trig_);
}
void App::drawTriggerBar() {
ImGui::BeginChild("trigbar", ImVec2(0.0f, ImGui::GetFrameHeightWithSpacing() + 6.0f),
false, ImGuiWindowFlags_NoScrollbar);
/* Signal. The list can change under us on a CONFIG re-send, so the
selection is re-resolved by name every frame. */
trigSignal_ = -1;
for (size_t i = 0u; i < sigs_.size(); i++) {
if (sigs_[i].name == trig_.signalName) {
trigSignal_ = static_cast<int>(i);
break;
}
}
const char* preview = (trigSignal_ >= 0) ? trig_.signalName.c_str() : "(none)";
ImGui::SetNextItemWidth(160.0f);
if (ImGui::BeginCombo("##trigsig", preview)) {
for (size_t i = 0u; i < sigs_.size(); i++) {
const bool sel = (static_cast<int>(i) == trigSignal_);
if (ImGui::Selectable(sigs_[i].name.c_str(), sel)) {
trig_.signalName = sigs_[i].name;
applyTrigConfig();
}
}
ImGui::EndCombo();
}
ImGui::SameLine();
const char* edges[] = {"rising", "falling", "both"};
int edge = static_cast<int>(trig_.edge);
ImGui::SetNextItemWidth(90.0f);
if (ImGui::Combo("##trigedge", &edge, edges, 3)) {
trig_.edge = static_cast<Edge>(edge);
applyTrigConfig();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(100.0f);
if (ImGui::InputDouble("thr", &trig_.threshold, 0.0, 0.0, "%.6g",
ImGuiInputTextFlags_EnterReturnsTrue)) {
applyTrigConfig();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(100.0f);
if (ImGui::InputDouble("hyst", &trig_.hysteresis, 0.0, 0.0, "%.6g",
ImGuiInputTextFlags_EnterReturnsTrue)) {
if (trig_.hysteresis < 0.0) {
trig_.hysteresis = 0.0;
}
applyTrigConfig();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(100.0f);
if (ImGui::InputDouble("win [s]", &trig_.windowSec, 0.0, 0.0, "%.6g",
ImGuiInputTextFlags_EnterReturnsTrue)) {
if (!(trig_.windowSec > 0.0)) {
trig_.windowSec = 0.1;
}
applyTrigConfig();
}
ImGui::SameLine();
float pre = static_cast<float>(trig_.prePercent);
ImGui::SetNextItemWidth(120.0f);
if (ImGui::SliderFloat("pre %", &pre, 0.0f, 90.0f, "%.0f")) {
trig_.prePercent = static_cast<double>(pre);
applyTrigConfig();
}
ImGui::SameLine();
int mode = (trig_.mode == TrigMode::Single) ? 1 : 0;
if (ImGui::RadioButton("Norm", mode == 0)) {
trig_.mode = TrigMode::Normal;
applyTrigConfig();
}
ImGui::SameLine();
if (ImGui::RadioButton("1x", mode == 1)) {
trig_.mode = TrigMode::Single;
applyTrigConfig();
}
ImGui::SameLine();
/* Badge. Amber while waiting for the pre-window to fill, green once the
capture is on screen. */
const TrigStatus ts = rx_.trigStatus();
ImVec4 badge(0.68f, 0.71f, 0.75f, 1.0f); /* overlay1 */
if (ts.state == TrigState::Armed) {
badge = ImVec4(0.98f, 0.70f, 0.53f, 1.0f); /* peach */
} else if (ts.state == TrigState::Collecting || ts.state == TrigState::Held) {
badge = ImVec4(0.65f, 0.89f, 0.63f, 1.0f); /* green */
}
ImGui::TextColored(badge, "%s", TrigBadge(ts).c_str());
ImGui::SameLine();
if (ImGui::Button("Arm")) {
applyTrigConfig();
rx_.arm();
}
ImGui::SameLine();
if (ImGui::Button("Disarm")) {
rx_.disarm();
}
ImGui::SameLine();
if (ImGui::Button("Re-arm")) {
rx_.rearm();
}
ImGui::SameLine();
bool follow = latch_.follow();
if (ImGui::Checkbox("follow", &follow)) {
latch_.setFollow(follow);
}
ImGui::SameLine();
ImGui::BeginDisabled(!latch_.showing());
if (ImGui::Button("Live")) {
latch_.returnToLive();
xaxis_.setLive(true);
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::TextDisabled("captures: %llu",
static_cast<unsigned long long>(ts.captures));
ImGui::EndChild();
ImGui::Separator();
}
} /* namespace udpscope */
Add TriggerBar.cpp to APP_SOURCES.
Why EnterReturnsTrue on the numeric fields: without it every keystroke
mid-edit is pushed to the receiver, so typing 0.05 momentarily configures a
window of 0, then 0.0, resetting the FSM three times and throwing away a
half-filled pre-window. The combos and slider have no such intermediate
states and apply immediately.
- Step 9: Wire the latch into the frame
In App::draw(), immediately after syncSignals() and before the panes are
drawn, add:
if (latch_.poll(store_)) {
/* A capture just arrived: pin the shared axis to its window. This
detaches the axis from live follow, which is what we want — the
user can then zoom inside the capture with the normal controls. */
xaxis_.userRange(latch_.x0(), latch_.x1());
}
and add drawTriggerBar(); to the frame between drawMenuBar() and the
signal list, matching spec §8.1's ordering.
In App::drawPlotArea(), replace ctx.capture = nullptr; with:
ctx.capture = latch_.showing() ? &latch_.capture() : nullptr;
- Step 10: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–13.
Against the demo streamer:
source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" -l RealTimeLoader \
-f Test/Configurations/streamhub_demo.cfg -s Running -m StateMachine:START &
./Client/udpscope/build/UDPScope --port 44501
- Drop a sine into a pane; the badge reads
IDLE. - Pick that signal in the trigger bar, threshold
0, window0.02, pre20,Norm, press Arm: the badge goesARMED 0%→ARMED 100%within a fraction of a second, then flashesTRIG'Don each trigger. - The pane freezes onto a stable waveform whose rising edge sits 20 % in from the left, and the capture counter climbs.
- Untick follow: the waveform stops updating while the counter keeps climbing. Tick it again: it jumps straight to the newest capture.
- Press Live: the pane scrolls again and the Live button greys out.
- Switch to
1xand press Arm: exactly one capture appears, the badge settles onHELD, and Re-arm produces the next one. - Set the threshold above the sine's amplitude: the badge stays
ARMED 100%and no new captures arrive.
- Step 11: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/TriggerBar.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trigger bar with arm/disarm and capture display"
Task 14: Cursors and measurements
Files:
- Create:
Client/udpscope/Measure.h - Create:
Client/udpscope/Measure.cpp - Create:
Client/udpscope/tests/MeasureTest.cpp - Modify:
Client/udpscope/PaneView.h - Modify:
Client/udpscope/PaneView.cpp - Modify:
Client/udpscope/App.h - Modify:
Client/udpscope/App.cpp - Modify:
Client/udpscope/CMakeLists.txt
Interfaces:
- Consumes:
Series(Task 1),TraceData::raw(Task 10),PaneContext(Tasks 10/12),VScale/ToDivisions(Task 12). - Produces:
struct Stats,ComputeStats(const Series&, double t0, double t1),bool SampleAt(const Series&, double t, double& v),struct Cursors(enabled,tA,tB,dt(),freq()). Task 15 persistsCursors.
Why statistics come from TraceData::raw: the drawn series is a min/max
envelope, two points per bucket. Its extremes are correct, but a mean or RMS
over it weights each bucket equally instead of each sample, so a burst of
1000 samples in one bucket would count the same as a bucket holding two.
Spec §8.4 requires true statistics, so ComputeStats reads the undecimated
series.
- Step 1: Write the failing tests
Create Client/udpscope/tests/MeasureTest.cpp:
#include "Measure.h"
#include <gtest/gtest.h>
#include <cmath>
using namespace udpscope;
namespace {
Series ramp() {
/* t = 0,1,2,3,4 v = 0,1,2,3,4 */
Series s;
for (int i = 0; i < 5; ++i) {
s.t.push_back(static_cast<double>(i));
s.v.push_back(static_cast<double>(i));
}
return s;
}
} // namespace
TEST(ComputeStats, EmptySeriesIsInvalid) {
Series s;
const Stats st = ComputeStats(s, 0.0, 1.0);
EXPECT_FALSE(st.valid);
EXPECT_EQ(st.count, 0u);
}
TEST(ComputeStats, ReportsMinMaxPeakToPeakMeanAndRms) {
Series s;
s.t = {0.0, 1.0, 2.0, 3.0};
s.v = {-2.0, 0.0, 0.0, 2.0};
const Stats st = ComputeStats(s, -1.0, 10.0);
ASSERT_TRUE(st.valid);
EXPECT_EQ(st.count, 4u);
EXPECT_DOUBLE_EQ(st.min, -2.0);
EXPECT_DOUBLE_EQ(st.max, 2.0);
EXPECT_DOUBLE_EQ(st.pp, 4.0);
EXPECT_DOUBLE_EQ(st.mean, 0.0);
EXPECT_NEAR(st.rms, std::sqrt(8.0 / 4.0), 1e-12);
}
// With cursors on, the statistics describe the span between them, not the
// whole visible trace.
TEST(ComputeStats, RestrictsItselfToTheGivenWindow) {
const Series s = ramp();
const Stats st = ComputeStats(s, 1.0, 3.0);
ASSERT_TRUE(st.valid);
EXPECT_EQ(st.count, 3u);
EXPECT_DOUBLE_EQ(st.min, 1.0);
EXPECT_DOUBLE_EQ(st.max, 3.0);
EXPECT_DOUBLE_EQ(st.mean, 2.0);
}
TEST(ComputeStats, ReversedWindowIsAcceptedAsIs) {
const Series s = ramp();
const Stats st = ComputeStats(s, 3.0, 1.0);
ASSERT_TRUE(st.valid);
EXPECT_EQ(st.count, 3u);
}
TEST(ComputeStats, WindowWithNoSamplesIsInvalid) {
const Series s = ramp();
const Stats st = ComputeStats(s, 10.0, 11.0);
EXPECT_FALSE(st.valid);
}
TEST(ComputeStats, IgnoresNonFiniteSamples) {
Series s;
s.t = {0.0, 1.0, 2.0};
s.v = {1.0, std::nan(""), 3.0};
const Stats st = ComputeStats(s, 0.0, 2.0);
ASSERT_TRUE(st.valid);
EXPECT_EQ(st.count, 2u);
EXPECT_DOUBLE_EQ(st.mean, 2.0);
}
TEST(SampleAt, InterpolatesBetweenSamples) {
const Series s = ramp();
double v = 0.0;
ASSERT_TRUE(SampleAt(s, 2.25, v));
EXPECT_DOUBLE_EQ(v, 2.25);
}
TEST(SampleAt, ReturnsTheEndpointsExactly) {
const Series s = ramp();
double v = 0.0;
ASSERT_TRUE(SampleAt(s, 0.0, v));
EXPECT_DOUBLE_EQ(v, 0.0);
ASSERT_TRUE(SampleAt(s, 4.0, v));
EXPECT_DOUBLE_EQ(v, 4.0);
}
// A cursor dragged off the end of the data has no value to report; it must
// not clamp, or the readout would silently lie.
TEST(SampleAt, FailsOutsideTheSeries) {
const Series s = ramp();
double v = 0.0;
EXPECT_FALSE(SampleAt(s, -0.5, v));
EXPECT_FALSE(SampleAt(s, 4.5, v));
Series empty;
EXPECT_FALSE(SampleAt(empty, 0.0, v));
}
TEST(SampleAt, HandlesRepeatedTimestamps) {
/* A min/max envelope stores two points at the same time. */
Series s;
s.t = {0.0, 1.0, 1.0, 2.0};
s.v = {0.0, -5.0, 5.0, 0.0};
double v = 0.0;
ASSERT_TRUE(SampleAt(s, 1.0, v));
EXPECT_TRUE(v == -5.0 || v == 5.0);
}
TEST(Cursors, DeltaAndFrequency) {
Cursors c;
c.tA = 1.0;
c.tB = 1.004;
EXPECT_NEAR(c.dt(), 0.004, 1e-15);
EXPECT_NEAR(c.freq(), 250.0, 1e-9);
}
// Both cursors on the same sample would divide by zero.
TEST(Cursors, ZeroSpanHasNoFrequency) {
Cursors c;
c.tA = 2.0;
c.tB = 2.0;
EXPECT_DOUBLE_EQ(c.dt(), 0.0);
EXPECT_DOUBLE_EQ(c.freq(), 0.0);
}
TEST(Cursors, DeltaIsSignedFromAToB) {
Cursors c;
c.tA = 3.0;
c.tB = 1.0;
EXPECT_DOUBLE_EQ(c.dt(), -2.0);
EXPECT_NEAR(c.freq(), 0.5, 1e-12);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Measure.h: No such file or directory.
- Step 3: Write
Measure.h
Create Client/udpscope/Measure.h:
/**
* @file Measure.h
* @brief Cursor readouts and per-trace statistics (spec §8.4).
*/
#ifndef UDPSCOPE_MEASURE_H
#define UDPSCOPE_MEASURE_H
#include "Types.h"
#include <cstddef>
namespace udpscope {
/** Statistics over the undecimated samples inside a time window. */
struct Stats {
bool valid = false;
size_t count = 0u;
double min = 0.0;
double max = 0.0;
double pp = 0.0; /**< max - min */
double mean = 0.0;
double rms = 0.0;
};
/**
* @param raw undecimated samples, time-ordered.
* @param t0,t1 window bounds, in either order; both ends inclusive.
*/
Stats ComputeStats(const Series& raw, double t0, double t1);
/**
* Linearly interpolates the trace at @a t.
* @return false when @a t is outside the series or the series is empty.
*/
bool SampleAt(const Series& raw, double t, double& v);
/** The two global time cursors. */
struct Cursors {
bool enabled = false;
double tA = 0.0;
double tB = 0.0;
double dt() const { return tB - tA; }
/** 1/|dt|, or 0 when the cursors coincide. */
double freq() const;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_MEASURE_H */
- Step 4: Write
Measure.cpp
Create Client/udpscope/Measure.cpp:
#include "Measure.h"
#include <algorithm>
#include <cmath>
namespace udpscope {
Stats ComputeStats(const Series& raw, double t0, double t1) {
Stats st;
if (t1 < t0) {
std::swap(t0, t1);
}
double sum = 0.0;
double sq = 0.0;
for (size_t i = 0u; i < raw.size(); i++) {
const double t = raw.t[i];
if (t < t0 || t > t1) {
continue;
}
const double v = raw.v[i];
if (!std::isfinite(v)) {
continue; /* a quantised NaN must not poison mean and RMS */
}
if (st.count == 0u) {
st.min = v;
st.max = v;
} else {
st.min = std::min(st.min, v);
st.max = std::max(st.max, v);
}
sum += v;
sq += v * v;
st.count++;
}
if (st.count == 0u) {
return st;
}
const double n = static_cast<double>(st.count);
st.pp = st.max - st.min;
st.mean = sum / n;
st.rms = std::sqrt(sq / n);
st.valid = true;
return st;
}
bool SampleAt(const Series& raw, double t, double& v) {
const size_t n = raw.size();
if (n == 0u || !std::isfinite(t)) {
return false;
}
if (t < raw.t[0] || t > raw.t[n - 1u]) {
return false;
}
/* First sample at or after t. Timestamps repeat in a min/max envelope,
so lower_bound may land on either member of a pair; both are equally
valid readings at that instant. */
const size_t hi = static_cast<size_t>(
std::lower_bound(raw.t.begin(), raw.t.end(), t) - raw.t.begin());
if (hi == 0u || raw.t[hi] == t) {
v = raw.v[hi];
return true;
}
const double t0 = raw.t[hi - 1u];
const double t1 = raw.t[hi];
const double dt = t1 - t0;
if (!(dt > 0.0)) {
v = raw.v[hi];
return true;
}
v = raw.v[hi - 1u] + (raw.v[hi] - raw.v[hi - 1u]) * (t - t0) / dt;
return true;
}
double Cursors::freq() const {
const double d = std::fabs(dt());
if (!(d > 0.0) || !std::isfinite(d)) {
return 0.0;
}
return 1.0 / d;
}
} /* namespace udpscope */
Add Measure.cpp to CORE_SOURCES in Client/udpscope/CMakeLists.txt.
- Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ComputeStats*:SampleAt*:Cursors*'
Expected: PASS, 13 tests.
- Step 6: Commit the measurement core
git add Client/udpscope/Measure.h Client/udpscope/Measure.cpp \
Client/udpscope/tests/MeasureTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): cursor sampling and undecimated trace statistics"
- Step 7: Draw the cursors and the statistics table
In Client/udpscope/PaneView.h, add #include "Measure.h" and two fields to
PaneContext:
Cursors* cursors = nullptr; /**< shared by every pane; may be null */
bool showStats = false;
In Client/udpscope/PaneView.cpp, inside drawLeaf()'s plot, after the trace
loop and before the legend popup handling, add:
if (ctx.cursors != nullptr && ctx.cursors->enabled) {
/* Dragging in any pane moves the cursors in all of them, because
they are one shared pair of times (spec §8.4). */
double a = ctx.cursors->tA;
double b = ctx.cursors->tB;
const ImVec4 amber(0.98f, 0.70f, 0.53f, 1.0f);
const ImVec4 blue(0.54f, 0.71f, 0.98f, 1.0f);
if (ImPlot::DragLineX(kCursorAId, &a, amber, 1.0f)) {
ctx.cursors->tA = a;
}
if (ImPlot::DragLineX(kCursorBId, &b, blue, 1.0f)) {
ctx.cursors->tB = b;
}
}
with the ids declared at file scope in PaneView.cpp:
namespace {
/* ImPlot drag-line ids must be unique within a plot but may repeat across
plots; A and B are the same logical cursors in every pane. */
const int kCursorAId = 1001;
const int kCursorBId = 1002;
} /* namespace */
Then, still inside the plot and after the cursor block, draw the readout as a plot annotation-free overlay:
const bool cursorsUp = (ctx.cursors != nullptr) && ctx.cursors->enabled;
if ((ctx.showStats || cursorsUp) && !traces_.empty()) {
/* Statistics are taken between the cursors when they are up, over the
visible range otherwise. */
const double s0 = cursorsUp ? ctx.cursors->tA : ctx.x0;
const double s1 = cursorsUp ? ctx.cursors->tB : ctx.x1;
ImPlot::PushPlotClipRect();
const ImVec2 org = ImPlot::GetPlotPos();
ImDrawList* dl = ImPlot::GetPlotDrawList();
float y = org.y + 4.0f;
for (size_t i = 0u; i < traces_.size(); i++) {
char line[288];
int used = std::snprintf(line, sizeof(line), "%s",
traces_[i].name.c_str());
if (cursorsUp) {
/* Spec §8.4: the value at each cursor and their difference,
per displayed signal. */
double va = 0.0, vb = 0.0;
const bool ha = SampleAt(traces_[i].raw, ctx.cursors->tA, va);
const bool hb = SampleAt(traces_[i].raw, ctx.cursors->tB, vb);
if (ha && hb) {
used += std::snprintf(line + used,
sizeof(line) - static_cast<size_t>(used),
" A %.4g B %.4g dV %.4g",
va, vb, vb - va);
} else {
used += std::snprintf(line + used,
sizeof(line) - static_cast<size_t>(used),
" A - B - dV -");
}
}
if (ctx.showStats) {
const Stats st = ComputeStats(traces_[i].raw, s0, s1);
if (st.valid) {
std::snprintf(line + used,
sizeof(line) - static_cast<size_t>(used),
" min %.4g max %.4g pp %.4g avg %.4g rms %.4g",
st.min, st.max, st.pp, st.mean, st.rms);
} else {
std::snprintf(line + used,
sizeof(line) - static_cast<size_t>(used),
" (no samples)");
}
}
dl->AddText(ImVec2(org.x + 6.0f, y),
ImGui::ColorConvertFloat4ToU32(toImVec4(traces_[i].color)),
line);
y += ImGui::GetTextLineHeight();
}
ImPlot::PopPlotClipRect();
}
The statistics need the raw series after the trace loop has finished, so
drawLeaf() must keep them. Add to PaneView's private section:
struct TraceKeep {
std::string name;
Color color;
Series raw;
};
std::vector<TraceKeep> traces_;
clear it at the top of the trace loop (traces_.clear();) and append inside
the loop, right after ComputeVScale(...):
if (ctx.showStats || (ctx.cursors != nullptr && ctx.cursors->enabled)) {
TraceKeep keep;
keep.name = a.signalName;
keep.color = a.color;
keep.raw = d.raw; /* copied: d is reused by the next trace */
traces_.push_back(keep);
}
- Step 8: Own the cursors in App
In App.h, add #include "Measure.h" and, next to latch_:
Cursors cursors_;
bool showStats_ = false;
In App.cpp, extend the View menu built in Task 12:
ImGui::Separator();
ImGui::MenuItem("Cursors", "C", &cursors_.enabled);
ImGui::MenuItem("Measurements", "M", &showStats_);
and pass them to the panes in drawPlotArea(), beside ctx.metas = &sigs_;:
ctx.cursors = &cursors_;
ctx.showStats = showStats_;
Place the cursors sensibly the first time they are switched on, otherwise
they sit at t=0, far off the left of a wall-clock axis. Immediately after the
View menu block in drawMenuBar():
if (cursors_.enabled && !cursorsPlaced_) {
const double span = xaxis_.span();
cursors_.tA = xaxis_.x0() + span * 0.25;
cursors_.tB = xaxis_.x0() + span * 0.75;
cursorsPlaced_ = true;
}
if (!cursors_.enabled) {
cursorsPlaced_ = false;
}
with bool cursorsPlaced_ = false; added next to showStats_.
Finally show the readout in the status bar. In drawStatusBar(), before the
existing counters:
if (cursors_.enabled) {
ImGui::Text("A %.6g s B %.6g s dt %.6g s 1/dt %.6g Hz",
cursors_.tA, cursors_.tB, cursors_.dt(), cursors_.freq());
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
- Step 9: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–14.
Against the demo streamer:
- View → Cursors: two vertical lines appear a quarter and three quarters of
the way across every pane, and the status bar shows
A,B,dt,1/dt. - Drag cursor A in one pane: it moves in every pane and the readout follows.
- Put the cursors one sine period apart:
1/dtreads the configured frequency of the demo sine. - With cursors on, each pane already lists
A,BanddVper trace in the trace colour; drag a cursor past the end of the data and that trace's three values become-. - View → Measurements: min/max/pp/avg/rms are appended to the same lines.
- With cursors on, narrow them to the top half of a sine:
avgrises andppshrinks, confirming the statistics track the cursor span rather than the visible range.
- Step 10: Commit
git add Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp
git commit -m "feat(udpscope): shared time cursors and per-pane measurements"
Task 15: Session persistence
Files:
- Create:
Client/udpscope/Settings.h - Create:
Client/udpscope/Settings.cpp - Create:
Client/udpscope/tests/SettingsTest.cpp - Modify:
Client/udpscope/App.h - Modify:
Client/udpscope/App.cpp - Modify:
Client/udpscope/CMakeLists.txt
Interfaces:
- Consumes:
PaneNode,Assignment,VScale,VMode,Orient,PaneTree::setRoot(Task 2);TrigConfig,Edge,TrigMode(Task 5);ReceiverOptions(Task 7);CliOptionsand itsset*flags,DefaultConfigPath()(Task 9);Cursors(Task 14). - Produces:
struct Session { ReceiverOptions source; TrigConfig trigger; Cursors cursors; std::unique_ptr<PaneNode> tree; },std::string WriteSession(const Session&),bool ParseSession(const std::string&, Session&, std::string&),bool LoadSessionFile(const std::string&, Session&, std::string&),bool SaveSessionFile(const std::string&, const Session&, std::string&),void MergeCli(const CliOptions&, ReceiverOptions&).
Why indentation is written but not parsed: the pane tree is written in
prefix order — a split line is always followed by exactly two subtrees, a
leaf line by its sig lines — so the structure is unambiguous from the
keywords alone. Indenting makes the file readable; requiring exact
indentation on the way back in would only add a way for a hand-edited file to
be rejected for no reason.
Deviation from the spec's example: the source line also carries
silence=. --silence is a source field the command line can set, and a
settings file that cannot round-trip everything the CLI sets would silently
drop it on the next save.
- Step 1: Write the failing tests
Create Client/udpscope/tests/SettingsTest.cpp:
#include "Settings.h"
#include <gtest/gtest.h>
#include <string>
using namespace udpscope;
namespace {
Session fullSession() {
Session s;
s.source.host = "10.0.0.5";
s.source.port = 44501u;
s.source.multicastGroup = "239.0.0.1";
s.source.interfaceAddr = "192.168.1.2";
s.source.dataPort = 44503u;
s.source.silenceTimeoutSec = 3.5;
s.trigger.signalName = "Voltage";
s.trigger.edge = Edge::Falling;
s.trigger.threshold = 0.5;
s.trigger.hysteresis = 0.01;
s.trigger.windowSec = 0.02;
s.trigger.prePercent = 30.0;
s.trigger.mode = TrigMode::Single;
s.cursors.enabled = true;
s.cursors.tA = 0.0123;
s.cursors.tB = 0.0456;
auto root = std::unique_ptr<PaneNode>(new PaneNode());
root->leaf = false;
root->orient = Orient::Columns;
root->ratio = 0.4;
auto left = std::unique_ptr<PaneNode>(new PaneNode());
Assignment v;
v.signalName = "Voltage";
v.color = Color{0.54f, 0.71f, 0.98f, 1.0f};
v.lineWidth = 1.5f;
v.vs.mode = VMode::Auto;
left->signals.push_back(v);
Assignment c;
c.signalName = "Current";
c.color = Color{0.98f, 0.70f, 0.53f, 1.0f};
c.lineWidth = 2.0f;
c.vs.mode = VMode::Manual;
c.vs.div = 0.2;
c.vs.offset = -1.0;
left->signals.push_back(c);
auto right = std::unique_ptr<PaneNode>(new PaneNode());
Assignment t;
t.signalName = "Temp";
t.color = Color{0.65f, 0.89f, 0.63f, 1.0f};
t.vs.mode = VMode::Range;
right->signals.push_back(t);
root->a = std::move(left);
root->b = std::move(right);
s.tree = std::move(root);
return s;
}
} // namespace
TEST(Settings, RoundTripsAFullSession) {
const Session in = fullSession();
const std::string text = WriteSession(in);
Session out;
std::string err;
ASSERT_TRUE(ParseSession(text, out, err)) << err;
EXPECT_EQ(out.source.host, "10.0.0.5");
EXPECT_EQ(out.source.port, 44501u);
EXPECT_EQ(out.source.multicastGroup, "239.0.0.1");
EXPECT_EQ(out.source.interfaceAddr, "192.168.1.2");
EXPECT_EQ(out.source.dataPort, 44503u);
EXPECT_DOUBLE_EQ(out.source.silenceTimeoutSec, 3.5);
EXPECT_EQ(out.trigger.signalName, "Voltage");
EXPECT_EQ(out.trigger.edge, Edge::Falling);
EXPECT_DOUBLE_EQ(out.trigger.threshold, 0.5);
EXPECT_DOUBLE_EQ(out.trigger.hysteresis, 0.01);
EXPECT_DOUBLE_EQ(out.trigger.windowSec, 0.02);
EXPECT_DOUBLE_EQ(out.trigger.prePercent, 30.0);
EXPECT_EQ(out.trigger.mode, TrigMode::Single);
EXPECT_TRUE(out.cursors.enabled);
EXPECT_NEAR(out.cursors.tA, 0.0123, 1e-9);
EXPECT_NEAR(out.cursors.tB, 0.0456, 1e-9);
ASSERT_TRUE(out.tree);
ASSERT_FALSE(out.tree->leaf);
EXPECT_EQ(out.tree->orient, Orient::Columns);
EXPECT_NEAR(out.tree->ratio, 0.4, 1e-9);
ASSERT_TRUE(out.tree->a && out.tree->b);
ASSERT_EQ(out.tree->a->signals.size(), 2u);
EXPECT_EQ(out.tree->a->signals[0].signalName, "Voltage");
EXPECT_EQ(out.tree->a->signals[1].vs.mode, VMode::Manual);
EXPECT_NEAR(out.tree->a->signals[1].vs.div, 0.2, 1e-9);
EXPECT_NEAR(out.tree->a->signals[1].vs.offset, -1.0, 1e-9);
EXPECT_NEAR(out.tree->a->signals[1].lineWidth, 2.0f, 1e-6f);
ASSERT_EQ(out.tree->b->signals.size(), 1u);
EXPECT_EQ(out.tree->b->signals[0].vs.mode, VMode::Range);
}
TEST(Settings, ColoursSurviveAsHex) {
const Session in = fullSession();
Session out;
std::string err;
ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
const Color got = out.tree->a->signals[0].color;
EXPECT_NEAR(got.r, 0.54f, 1.0f / 255.0f);
EXPECT_NEAR(got.g, 0.71f, 1.0f / 255.0f);
EXPECT_NEAR(got.b, 0.98f, 1.0f / 255.0f);
}
TEST(Settings, TheWrittenFormMatchesTheDocumentedShape) {
const Session in = fullSession();
const std::string text = WriteSession(in);
EXPECT_EQ(text.compare(0, 11, "udpscope 1\n"), 0);
EXPECT_NE(text.find("\nsource host=10.0.0.5 port=44501"), std::string::npos);
EXPECT_NE(text.find("\ncursors on "), std::string::npos);
EXPECT_NE(text.find("\ntree\n"), std::string::npos);
EXPECT_NE(text.find("split cols 0.4"), std::string::npos);
EXPECT_NE(text.find("sig Voltage color=#"), std::string::npos);
}
TEST(Settings, ASessionWithoutATreeIsValid) {
Session in;
in.trigger.signalName = "Voltage";
Session out;
std::string err;
ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
EXPECT_FALSE(out.tree);
EXPECT_EQ(out.trigger.signalName, "Voltage");
}
TEST(Settings, CursorsOffRoundTrips) {
Session in;
in.cursors.enabled = false;
Session out;
out.cursors.enabled = true;
std::string err;
ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
EXPECT_FALSE(out.cursors.enabled);
}
TEST(Settings, EveryEdgeAndModeNameRoundTrips) {
const Edge edges[] = {Edge::Rising, Edge::Falling, Edge::Both};
const TrigMode modes[] = {TrigMode::Normal, TrigMode::Single};
for (int e = 0; e < 3; ++e) {
for (int m = 0; m < 2; ++m) {
Session in;
in.trigger.edge = edges[e];
in.trigger.mode = modes[m];
Session out;
std::string err;
ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
EXPECT_EQ(out.trigger.edge, edges[e]);
EXPECT_EQ(out.trigger.mode, modes[m]);
}
}
}
// The file is meant to be hand-editable, so re-indenting it must not break it.
TEST(Settings, IndentationIsIgnored) {
const std::string text =
"udpscope 1\n"
"tree\n"
"split rows 0.25\n"
"leaf\n"
"sig A color=#ffffff width=1 vs=auto\n"
" leaf\n"
"\t\tsig B color=#000000 width=1 vs=auto\n";
Session out;
std::string err;
ASSERT_TRUE(ParseSession(text, out, err)) << err;
ASSERT_TRUE(out.tree && !out.tree->leaf);
EXPECT_EQ(out.tree->orient, Orient::Rows);
EXPECT_EQ(out.tree->a->signals[0].signalName, "A");
EXPECT_EQ(out.tree->b->signals[0].signalName, "B");
}
TEST(Settings, BlankLinesAndCommentsAreSkipped) {
const std::string text =
"udpscope 1\n"
"\n"
"# written by hand\n"
"tree\n"
" leaf\n";
Session out;
std::string err;
ASSERT_TRUE(ParseSession(text, out, err)) << err;
ASSERT_TRUE(out.tree);
EXPECT_TRUE(out.tree->leaf);
}
TEST(Settings, MissingHeaderIsRejected) {
Session out;
std::string err;
EXPECT_FALSE(ParseSession("tree\n leaf\n", out, err));
EXPECT_FALSE(err.empty());
}
TEST(Settings, VersionMismatchIsRejected) {
Session out;
std::string err;
EXPECT_FALSE(ParseSession("udpscope 2\ntree\n leaf\n", out, err));
EXPECT_NE(err.find("version"), std::string::npos);
}
TEST(Settings, UnknownKeywordIsRejected) {
Session out;
std::string err;
EXPECT_FALSE(ParseSession("udpscope 1\nbananas 3\n", out, err));
}
TEST(Settings, UnknownKeyIsRejected) {
Session out;
std::string err;
EXPECT_FALSE(ParseSession("udpscope 1\nsource host=a bogus=1\n", out, err));
}
TEST(Settings, TruncatedTreeIsRejected) {
Session out;
std::string err;
/* A split promises two children and only delivers one. */
EXPECT_FALSE(ParseSession("udpscope 1\ntree\n split cols 0.5\n leaf\n",
out, err));
}
// Spec §9: a bad file is reported and ignored, never partially applied.
TEST(Settings, AFailedParseLeavesTheOutputUntouched) {
Session out;
out.source.host = "keepme";
out.trigger.signalName = "keepme-too";
std::string err;
ASSERT_FALSE(ParseSession("udpscope 1\n"
"source host=clobbered port=1\n"
"trigger signal=clobbered\n"
"bananas\n",
out, err));
EXPECT_EQ(out.source.host, "keepme");
EXPECT_EQ(out.trigger.signalName, "keepme-too");
}
// Signal names are bare tokens in the file format.
TEST(Settings, WhitespaceInASignalNameIsRefusedOnWrite) {
Session in;
in.tree = std::unique_ptr<PaneNode>(new PaneNode());
Assignment a;
a.signalName = "bad name";
in.tree->signals.push_back(a);
std::string err;
EXPECT_FALSE(SaveSessionFile("/tmp/udpscope_should_not_exist.conf", in, err));
EXPECT_FALSE(err.empty());
}
TEST(Settings, SaveThenLoadAFile) {
const std::string path = "/tmp/udpscope_settings_test.conf";
const Session in = fullSession();
std::string err;
ASSERT_TRUE(SaveSessionFile(path, in, err)) << err;
Session out;
ASSERT_TRUE(LoadSessionFile(path, out, err)) << err;
EXPECT_EQ(out.source.port, 44501u);
ASSERT_TRUE(out.tree);
std::remove(path.c_str());
}
TEST(Settings, LoadingAMissingFileFails) {
Session out;
std::string err;
EXPECT_FALSE(LoadSessionFile("/tmp/udpscope_definitely_absent.conf", out, err));
}
// Spec §11: an explicit option wins, an omitted one falls back to the file.
TEST(MergeCli, ExplicitOptionsWin) {
ReceiverOptions fromFile;
fromFile.host = "10.0.0.5";
fromFile.port = 44501u;
CliOptions cli;
cli.source.port = 44502u;
cli.setPort = true;
MergeCli(cli, fromFile);
EXPECT_EQ(fromFile.host, "10.0.0.5"); // not given on the command line
EXPECT_EQ(fromFile.port, 44502u); // given, so it wins
}
TEST(MergeCli, OmittedOptionsLeaveTheFileAlone) {
ReceiverOptions fromFile;
fromFile.host = "10.0.0.5";
fromFile.multicastGroup = "239.0.0.1";
fromFile.dataPort = 44503u;
CliOptions cli; // nothing given
MergeCli(cli, fromFile);
EXPECT_EQ(fromFile.host, "10.0.0.5");
EXPECT_EQ(fromFile.multicastGroup, "239.0.0.1");
EXPECT_EQ(fromFile.dataPort, 44503u);
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Settings.h: No such file or directory.
- Step 3: Write
Settings.h
Create Client/udpscope/Settings.h:
/**
* @file Settings.h
* @brief The line-based session file described in spec §9.
*/
#ifndef UDPSCOPE_SETTINGS_H
#define UDPSCOPE_SETTINGS_H
#include "Cli.h"
#include "Measure.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "Trigger.h"
#include <memory>
#include <string>
namespace udpscope {
/** Everything that survives a restart. */
struct Session {
ReceiverOptions source;
TrigConfig trigger;
Cursors cursors;
std::unique_ptr<PaneNode> tree; /**< null when nothing was saved */
};
/** Current file format version; anything else is refused. */
constexpr int kSessionVersion = 1;
/** @return the session as text, always ending in a newline. */
std::string WriteSession(const Session& s);
/**
* Parses @a text. On failure @a out is left exactly as it was, so a broken
* file can never half-apply.
*/
bool ParseSession(const std::string& text, Session& out, std::string& err);
bool LoadSessionFile(const std::string& path, Session& out, std::string& err);
/** Creates the parent directory if needed. */
bool SaveSessionFile(const std::string& path, const Session& s, std::string& err);
/** Applies the options that were actually given on the command line. */
void MergeCli(const CliOptions& cli, ReceiverOptions& io);
} /* namespace udpscope */
#endif /* UDPSCOPE_SETTINGS_H */
- Step 4: Write
Settings.cpp
Create Client/udpscope/Settings.cpp:
#include "Settings.h"
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
#include <vector>
namespace udpscope {
namespace {
std::vector<std::string> tokens(const std::string& line) {
std::vector<std::string> out;
std::istringstream is(line);
std::string t;
while (is >> t) {
out.push_back(t);
}
return out;
}
bool splitKv(const std::string& tok, std::string& k, std::string& v) {
const size_t eq = tok.find('=');
if (eq == std::string::npos) {
return false;
}
k = tok.substr(0u, eq);
v = tok.substr(eq + 1u);
return true;
}
double toD(const std::string& s) { return std::strtod(s.c_str(), NULL); }
unsigned toU(const std::string& s) {
return static_cast<unsigned>(std::strtoul(s.c_str(), NULL, 10));
}
std::string hexOf(const Color& c) {
const int r = static_cast<int>(c.r * 255.0f + 0.5f);
const int g = static_cast<int>(c.g * 255.0f + 0.5f);
const int b = static_cast<int>(c.b * 255.0f + 0.5f);
char buf[16];
std::snprintf(buf, sizeof(buf), "#%02x%02x%02x",
r < 0 ? 0 : (r > 255 ? 255 : r),
g < 0 ? 0 : (g > 255 ? 255 : g),
b < 0 ? 0 : (b > 255 ? 255 : b));
return std::string(buf);
}
bool colorOf(const std::string& s, Color& c) {
if (s.size() != 7u || s[0] != '#') {
return false;
}
for (size_t i = 1u; i < 7u; i++) {
if (std::isxdigit(static_cast<unsigned char>(s[i])) == 0) {
return false;
}
}
const unsigned long v = std::strtoul(s.c_str() + 1, NULL, 16);
c.r = static_cast<float>((v >> 16) & 0xffu) / 255.0f;
c.g = static_cast<float>((v >> 8) & 0xffu) / 255.0f;
c.b = static_cast<float>(v & 0xffu) / 255.0f;
c.a = 1.0f;
return true;
}
const char* edgeName(Edge e) {
switch (e) {
case Edge::Falling: return "falling";
case Edge::Both: return "both";
case Edge::Rising:
default: return "rising";
}
}
bool edgeOf(const std::string& s, Edge& e) {
if (s == "rising") { e = Edge::Rising; return true; }
if (s == "falling") { e = Edge::Falling; return true; }
if (s == "both") { e = Edge::Both; return true; }
return false;
}
const char* vmodeName(VMode m) {
switch (m) {
case VMode::Range: return "range";
case VMode::Manual: return "manual";
case VMode::Auto:
default: return "auto";
}
}
bool vmodeOf(const std::string& s, VMode& m) {
if (s == "auto") { m = VMode::Auto; return true; }
if (s == "range") { m = VMode::Range; return true; }
if (s == "manual") { m = VMode::Manual; return true; }
return false;
}
bool hasSpace(const std::string& s) {
for (size_t i = 0u; i < s.size(); i++) {
if (std::isspace(static_cast<unsigned char>(s[i])) != 0) {
return true;
}
}
return s.empty();
}
void writeNode(const PaneNode& n, int depth, std::string& out) {
const std::string pad(static_cast<size_t>(depth) * 2u, ' ');
char buf[256];
if (!n.leaf) {
std::snprintf(buf, sizeof(buf), "%ssplit %s %.6g\n", pad.c_str(),
(n.orient == Orient::Rows) ? "rows" : "cols", n.ratio);
out += buf;
if (n.a) { writeNode(*n.a, depth + 1, out); }
if (n.b) { writeNode(*n.b, depth + 1, out); }
return;
}
out += pad + "leaf\n";
for (size_t i = 0u; i < n.signals.size(); i++) {
const Assignment& a = n.signals[i];
std::snprintf(buf, sizeof(buf), "%s sig %s color=%s width=%.4g vs=%s",
pad.c_str(), a.signalName.c_str(), hexOf(a.color).c_str(),
static_cast<double>(a.lineWidth), vmodeName(a.vs.mode));
out += buf;
if (a.vs.mode == VMode::Manual) {
std::snprintf(buf, sizeof(buf), " div=%.10g off=%.10g",
a.vs.div, a.vs.offset);
out += buf;
}
out += "\n";
}
}
/** True if every signal name in the tree is a bare token. */
bool namesAreWritable(const PaneNode& n, std::string& bad) {
if (n.leaf) {
for (size_t i = 0u; i < n.signals.size(); i++) {
if (hasSpace(n.signals[i].signalName)) {
bad = n.signals[i].signalName;
return false;
}
}
return true;
}
if (n.a && !namesAreWritable(*n.a, bad)) { return false; }
if (n.b && !namesAreWritable(*n.b, bad)) { return false; }
return true;
}
/** Recursive descent over the pre-tokenised lines; @a i is the cursor. */
std::unique_ptr<PaneNode> readNode(const std::vector<std::vector<std::string> >& L,
size_t& i, std::string& err) {
if (i >= L.size()) {
err = "tree ends early";
return std::unique_ptr<PaneNode>();
}
const std::vector<std::string>& t = L[i];
std::unique_ptr<PaneNode> n(new PaneNode());
if (t[0] == "split") {
if (t.size() != 3u) {
err = "split needs an orientation and a ratio";
return std::unique_ptr<PaneNode>();
}
n->leaf = false;
if (t[1] == "cols") {
n->orient = Orient::Columns;
} else if (t[1] == "rows") {
n->orient = Orient::Rows;
} else {
err = "unknown split orientation '" + t[1] + "'";
return std::unique_ptr<PaneNode>();
}
n->ratio = toD(t[2]);
if (!(n->ratio > 0.0) || !(n->ratio < 1.0)) {
err = "split ratio out of range";
return std::unique_ptr<PaneNode>();
}
i++;
n->a = readNode(L, i, err);
if (!n->a) { return std::unique_ptr<PaneNode>(); }
n->b = readNode(L, i, err);
if (!n->b) { return std::unique_ptr<PaneNode>(); }
return n;
}
if (t[0] != "leaf") {
err = "expected 'split' or 'leaf', got '" + t[0] + "'";
return std::unique_ptr<PaneNode>();
}
i++;
while (i < L.size() && L[i][0] == "sig") {
const std::vector<std::string>& s = L[i];
if (s.size() < 2u) {
err = "sig needs a name";
return std::unique_ptr<PaneNode>();
}
Assignment a;
a.signalName = s[1];
for (size_t k = 2u; k < s.size(); k++) {
std::string key, val;
if (!splitKv(s[k], key, val)) {
err = "sig field '" + s[k] + "' is not key=value";
return std::unique_ptr<PaneNode>();
}
if (key == "color") {
if (!colorOf(val, a.color)) {
err = "bad colour '" + val + "'";
return std::unique_ptr<PaneNode>();
}
} else if (key == "width") {
a.lineWidth = static_cast<float>(toD(val));
} else if (key == "vs") {
if (!vmodeOf(val, a.vs.mode)) {
err = "bad vertical mode '" + val + "'";
return std::unique_ptr<PaneNode>();
}
} else if (key == "div") {
a.vs.div = toD(val);
} else if (key == "off") {
a.vs.offset = toD(val);
} else {
err = "unknown sig key '" + key + "'";
return std::unique_ptr<PaneNode>();
}
}
n->signals.push_back(a);
i++;
}
return n;
}
} /* namespace */
std::string WriteSession(const Session& s) {
std::string out;
char buf[512];
std::snprintf(buf, sizeof(buf), "udpscope %d\n", kSessionVersion);
out += buf;
std::snprintf(buf, sizeof(buf),
"source host=%s port=%u multicast=%s iface=%s dataport=%u "
"silence=%.6g\n",
s.source.host.c_str(), static_cast<unsigned>(s.source.port),
s.source.multicastGroup.c_str(), s.source.interfaceAddr.c_str(),
static_cast<unsigned>(s.source.dataPort),
s.source.silenceTimeoutSec);
out += buf;
std::snprintf(buf, sizeof(buf),
"trigger signal=%s edge=%s thr=%.10g hyst=%.10g win=%.10g "
"pre=%.6g mode=%s\n",
s.trigger.signalName.c_str(), edgeName(s.trigger.edge),
s.trigger.threshold, s.trigger.hysteresis, s.trigger.windowSec,
s.trigger.prePercent,
(s.trigger.mode == TrigMode::Single) ? "single" : "normal");
out += buf;
std::snprintf(buf, sizeof(buf), "cursors %s %.10g %.10g\n",
s.cursors.enabled ? "on" : "off", s.cursors.tA, s.cursors.tB);
out += buf;
if (s.tree) {
out += "tree\n";
writeNode(*s.tree, 1, out);
}
return out;
}
bool ParseSession(const std::string& text, Session& out, std::string& err) {
/* Everything lands in a scratch session and is moved out only on success
(spec §9: never partially applied). */
Session s;
std::vector<std::vector<std::string> > lines;
{
std::istringstream is(text);
std::string line;
while (std::getline(is, line)) {
std::vector<std::string> t = tokens(line);
if (t.empty() || t[0][0] == '#') {
continue;
}
lines.push_back(t);
}
}
if (lines.empty() || lines[0].size() != 2u || lines[0][0] != "udpscope") {
err = "not a udpscope session file";
return false;
}
if (std::atoi(lines[0][1].c_str()) != kSessionVersion) {
err = "unsupported session version '" + lines[0][1] + "'";
return false;
}
for (size_t i = 1u; i < lines.size(); ) {
const std::vector<std::string>& t = lines[i];
if (t[0] == "source" || t[0] == "trigger") {
for (size_t k = 1u; k < t.size(); k++) {
std::string key, val;
if (!splitKv(t[k], key, val)) {
err = "field '" + t[k] + "' is not key=value";
return false;
}
if (t[0] == "source") {
if (key == "host") { s.source.host = val; }
else if (key == "port") { s.source.port = static_cast<uint16_t>(toU(val)); }
else if (key == "multicast") { s.source.multicastGroup = val; }
else if (key == "iface") { s.source.interfaceAddr = val; }
else if (key == "dataport") { s.source.dataPort = static_cast<uint16_t>(toU(val)); }
else if (key == "silence") { s.source.silenceTimeoutSec = toD(val); }
else { err = "unknown source key '" + key + "'"; return false; }
} else {
if (key == "signal") { s.trigger.signalName = val; }
else if (key == "edge") {
if (!edgeOf(val, s.trigger.edge)) {
err = "unknown edge '" + val + "'";
return false;
}
}
else if (key == "thr") { s.trigger.threshold = toD(val); }
else if (key == "hyst") { s.trigger.hysteresis = toD(val); }
else if (key == "win") { s.trigger.windowSec = toD(val); }
else if (key == "pre") { s.trigger.prePercent = toD(val); }
else if (key == "mode") {
if (val == "single") { s.trigger.mode = TrigMode::Single; }
else if (val == "normal") { s.trigger.mode = TrigMode::Normal; }
else { err = "unknown trigger mode '" + val + "'"; return false; }
}
else { err = "unknown trigger key '" + key + "'"; return false; }
}
}
i++;
continue;
}
if (t[0] == "cursors") {
if (t.size() != 4u) {
err = "cursors needs on|off and two times";
return false;
}
s.cursors.enabled = (t[1] == "on");
s.cursors.tA = toD(t[2]);
s.cursors.tB = toD(t[3]);
i++;
continue;
}
if (t[0] == "tree") {
i++;
s.tree = readNode(lines, i, err);
if (!s.tree) {
return false;
}
if (i != lines.size()) {
err = "trailing content after the tree: '" + lines[i][0] + "'";
return false;
}
continue;
}
err = "unknown keyword '" + t[0] + "'";
return false;
}
out.source = s.source;
out.trigger = s.trigger;
out.cursors = s.cursors;
out.tree = std::move(s.tree);
return true;
}
bool LoadSessionFile(const std::string& path, Session& out, std::string& err) {
std::ifstream f(path.c_str());
if (!f) {
err = "cannot open " + path;
return false;
}
std::ostringstream ss;
ss << f.rdbuf();
return ParseSession(ss.str(), out, err);
}
bool SaveSessionFile(const std::string& path, const Session& s, std::string& err) {
std::string bad;
if (s.tree && !namesAreWritable(*s.tree, bad)) {
err = "signal name '" + bad + "' cannot be written to the session file";
return false;
}
const size_t slash = path.find_last_of('/');
if (slash != std::string::npos && slash > 0u) {
const std::string dir = path.substr(0u, slash);
if (mkdir(dir.c_str(), 0755) != 0 && errno != EEXIST) {
err = "cannot create " + dir + ": " + std::strerror(errno);
return false;
}
}
std::ofstream f(path.c_str(), std::ios::trunc);
if (!f) {
err = "cannot write " + path;
return false;
}
f << WriteSession(s);
if (!f) {
err = "write failed for " + path;
return false;
}
return true;
}
void MergeCli(const CliOptions& cli, ReceiverOptions& io) {
if (cli.setHost) { io.host = cli.source.host; }
if (cli.setPort) { io.port = cli.source.port; }
if (cli.setMulticast) { io.multicastGroup = cli.source.multicastGroup; }
if (cli.setIface) { io.interfaceAddr = cli.source.interfaceAddr; }
if (cli.setDataPort) { io.dataPort = cli.source.dataPort; }
if (cli.setSilence) { io.silenceTimeoutSec = cli.source.silenceTimeoutSec; }
}
} /* namespace udpscope */
Add Settings.cpp to CORE_SOURCES. mkdir only creates the last component,
which is enough for ~/.config/udpscope; a missing ~/.config is reported
rather than created.
- Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Settings*:MergeCli*'
Expected: PASS, 18 tests.
- Step 6: Commit the session core
git add Client/udpscope/Settings.h Client/udpscope/Settings.cpp \
Client/udpscope/tests/SettingsTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): session file writer, parser and CLI precedence"
- Step 7: Load the session before the receiver starts
In App.h add #include "Settings.h" and these members next to opt_:
std::string configPath_;
bool dirty_ = false; /**< something worth saving has changed */
and declare:
void loadSession();
void saveSession();
Session currentSession() const;
Replace the body of App::App() in App.cpp with:
App::App(const CliOptions& opt) : opt_(opt), rx_(store_) {
configPath_ = opt_.setConfigPath ? opt_.configPath : DefaultConfigPath();
loadSession();
std::string err;
if (!rx_.start(opt_.source, err)) {
status_ = "receiver failed to start: " + err;
} else {
char buf[160];
std::snprintf(buf, sizeof(buf), "attaching to %s:%u",
opt_.source.host.c_str(),
static_cast<unsigned>(opt_.source.port));
status_ = buf;
}
rx_.setTrigConfig(trig_);
}
void App::loadSession() {
Session s;
std::string err;
if (!LoadSessionFile(configPath_, s, err)) {
/* No session yet is the normal first run, not a failure worth
shouting about; a malformed one is (spec §9). */
status_ = err;
return; /* opt_.source already holds defaults plus the command line */
}
/* File first, command line on top. */
ReceiverOptions merged = s.source;
MergeCli(opt_, merged);
opt_.source = merged;
trig_ = s.trigger;
cursors_ = s.cursors;
if (s.tree) {
tree_.setRoot(std::move(s.tree));
}
}
Session App::currentSession() const {
Session s;
s.source = opt_.source;
s.trigger = trig_;
s.cursors = cursors_;
s.tree = ClonePane(tree_.root());
return s;
}
void App::saveSession() {
const Session s = currentSession();
std::string err;
if (SaveSessionFile(configPath_, s, err)) {
status_ = "saved " + configPath_;
dirty_ = false;
} else {
status_ = err;
}
}
currentSession() needs a deep copy of the live tree, because Session owns
its nodes and the tree keeps drawing. Add to PaneTree.h:
/** Deep-copies a subtree; returns null for a null input. */
std::unique_ptr<PaneNode> ClonePane(const PaneNode* n);
and to PaneTree.cpp:
std::unique_ptr<PaneNode> ClonePane(const PaneNode* n) {
if (n == NULL) {
return std::unique_ptr<PaneNode>();
}
std::unique_ptr<PaneNode> c(new PaneNode());
c->leaf = n->leaf;
c->signals = n->signals;
c->profilePane = n->profilePane;
c->orient = n->orient;
c->ratio = n->ratio;
c->a = ClonePane(n->a.get());
c->b = ClonePane(n->b.get());
return c;
}
Add a round-trip test to Client/udpscope/tests/PaneTreeTest.cpp:
TEST(PaneTree, CloneIsADeepCopy) {
PaneTree tree;
tree.splitLeaf(tree.root(), Orient::Rows);
Assignment a;
a.signalName = "Voltage";
tree.root()->a->signals.push_back(a);
std::unique_ptr<PaneNode> copy = ClonePane(tree.root());
ASSERT_TRUE(copy && !copy->leaf);
ASSERT_EQ(copy->a->signals.size(), 1u);
EXPECT_EQ(copy->a->signals[0].signalName, "Voltage");
copy->a->signals[0].signalName = "Other";
EXPECT_EQ(tree.root()->a->signals[0].signalName, "Voltage");
}
- Step 8: Add the File menu and save on exit
In App::drawMenuBar(), replace the File menu block Task 9 wrote (the one
whose only item is Quit) — do not add a second one:
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Save Layout", "Ctrl+S")) {
saveSession();
}
if (ImGui::MenuItem("Reload Layout")) {
loadSession();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit", "Ctrl+Q")) {
requestQuit();
}
ImGui::EndMenu();
}
and in App::~App():
App::~App() {
saveSession(); /* spec §9: saved on clean exit */
rx_.stop();
}
- Step 9: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–15.
Against the demo streamer:
./Client/udpscope/build/UDPScope --port 44501
- Split into three panes, drop different signals in each, set one to manual v-scale, turn cursors on, configure the trigger. Quit.
cat ~/.config/udpscope/session.confshows the documented shape.- Relaunch with no arguments: the same three panes, signals, colours, v-scales, cursors and trigger settings come back, and it reconnects to port 44501 because the file recorded it.
- Relaunch with
--port 44502: the layout is restored but the source is the command-line one; the status bar shows the new port. - Append
bananasto the file and relaunch: the status bar reportsunknown keyword 'bananas'and the app opens with a single empty pane — nothing half-applied.
- Step 10: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/tests/PaneTreeTest.cpp
git commit -m "feat(udpscope): restore and save the session layout"
Task 16: CSV export
Files:
- Create:
Client/udpscope/Export.h - Create:
Client/udpscope/Export.cpp - Create:
Client/udpscope/tests/ExportTest.cpp - Modify:
Client/udpscope/App.h - Modify:
Client/udpscope/App.cpp - Modify:
Client/udpscope/CMakeLists.txt
Interfaces:
- Consumes:
Series(Task 1),PaneNode(Task 2),SignalStoreandCapture(Task 6),FetchLiveTrace/FetchCaptureTrace(Task 10),CaptureLatch(Task 13). - Produces:
struct CsvTrace { std::string name; Series data; },std::string BuildCsv(const std::vector<CsvTrace>&, double t0),bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>&, double t0, std::string& err),std::vector<std::string> SignalsInPane(const PaneNode*).
Column meanings: time_s is relative to the start of the exported
window, so a capture reads from 0 regardless of when it was taken;
wallclock_s is the absolute Unix time the sample carries. Precision is
9 decimals on time_s (the spec's example shows 6, which is exactly one
microsecond and would collide at the 1 MSps rates this scope is aimed at),
6 decimals on wallclock_s, and %.10g on the value, which round-trips a
float32 and every 16-bit quantised value exactly.
- Step 1: Write the failing tests
Create Client/udpscope/tests/ExportTest.cpp:
#include "Export.h"
#include <gtest/gtest.h>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
using namespace udpscope;
namespace {
CsvTrace trace(const std::string& name, const std::vector<double>& t,
const std::vector<double>& v) {
CsvTrace c;
c.name = name;
c.data.t = t;
c.data.v = v;
return c;
}
std::vector<std::string> linesOf(const std::string& s) {
std::vector<std::string> out;
std::istringstream is(s);
std::string line;
while (std::getline(is, line)) {
out.push_back(line);
}
return out;
}
} // namespace
TEST(BuildCsv, StartsWithTheDocumentedHeader) {
std::vector<CsvTrace> tr;
const std::vector<std::string> l = linesOf(BuildCsv(tr, 0.0));
ASSERT_EQ(l.size(), 1u);
EXPECT_EQ(l[0], "signal,time_s,wallclock_s,value");
}
TEST(BuildCsv, WritesOneRowPerSampleInLongFormat) {
std::vector<CsvTrace> tr;
tr.push_back(trace("Voltage", {1000.0, 1000.5}, {0.25, -0.5}));
tr.push_back(trace("Current", {1000.25}, {2.0}));
const std::vector<std::string> l = linesOf(BuildCsv(tr, 1000.0));
ASSERT_EQ(l.size(), 4u);
EXPECT_EQ(l[1], "Voltage,0.000000000,1000.000000,0.25");
EXPECT_EQ(l[2], "Voltage,0.500000000,1000.500000,-0.5");
EXPECT_EQ(l[3], "Current,0.250000000,1000.250000,2");
}
// A capture starts at t0, so the relative column reads from zero whenever the
// export was taken.
TEST(BuildCsv, TimeIsRelativeToTheWindowStart) {
std::vector<CsvTrace> tr;
tr.push_back(trace("S", {1756291200.123456}, {1.0}));
const std::vector<std::string> l = linesOf(BuildCsv(tr, 1756291200.0));
ASSERT_EQ(l.size(), 2u);
EXPECT_EQ(l[1].compare(0, 14, "S,0.123456000"), 0) << l[1];
EXPECT_NE(l[1].find(",1756291200.123456,"), std::string::npos) << l[1];
}
TEST(BuildCsv, AnEmptyTraceContributesNoRows) {
std::vector<CsvTrace> tr;
tr.push_back(trace("Empty", {}, {}));
tr.push_back(trace("S", {1.0}, {1.0}));
EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u);
}
TEST(BuildCsv, MismatchedTimeAndValueCountsAreTruncated) {
CsvTrace c;
c.name = "S";
c.data.t = {1.0, 2.0, 3.0};
c.data.v = {1.0};
std::vector<CsvTrace> tr;
tr.push_back(c);
EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u);
}
TEST(ExportCsvFile, WritesWhatBuildCsvProduces) {
const std::string path = "/tmp/udpscope_export_test.csv";
std::vector<CsvTrace> tr;
tr.push_back(trace("S", {1.0, 2.0}, {3.0, 4.0}));
std::string err;
ASSERT_TRUE(ExportCsvFile(path, tr, 1.0, err)) << err;
std::ifstream f(path.c_str());
std::ostringstream ss;
ss << f.rdbuf();
EXPECT_EQ(ss.str(), BuildCsv(tr, 1.0));
std::remove(path.c_str());
}
TEST(ExportCsvFile, ReportsAnUnwritablePath) {
std::vector<CsvTrace> tr;
std::string err;
EXPECT_FALSE(ExportCsvFile("/proc/definitely/not/here.csv", tr, 0.0, err));
EXPECT_FALSE(err.empty());
}
TEST(SignalsInPane, ListsALeafInOrder) {
PaneNode leaf;
Assignment a;
a.signalName = "B";
leaf.signals.push_back(a);
a.signalName = "A";
leaf.signals.push_back(a);
const std::vector<std::string> got = SignalsInPane(&leaf);
ASSERT_EQ(got.size(), 2u);
EXPECT_EQ(got[0], "B");
EXPECT_EQ(got[1], "A");
}
// Exporting "all panes" must not write the same signal twice when it is
// dropped into two panes for comparison.
TEST(SignalsInPane, WalksTheTreeDepthFirstWithoutDuplicates) {
PaneNode root;
root.leaf = false;
root.a = std::unique_ptr<PaneNode>(new PaneNode());
root.b = std::unique_ptr<PaneNode>(new PaneNode());
Assignment a;
a.signalName = "X";
root.a->signals.push_back(a);
a.signalName = "Y";
root.a->signals.push_back(a);
a.signalName = "X";
root.b->signals.push_back(a);
const std::vector<std::string> got = SignalsInPane(&root);
ASSERT_EQ(got.size(), 2u);
EXPECT_EQ(got[0], "X");
EXPECT_EQ(got[1], "Y");
}
TEST(SignalsInPane, HandlesNullAndEmpty) {
EXPECT_TRUE(SignalsInPane(NULL).empty());
PaneNode empty;
EXPECT_TRUE(SignalsInPane(&empty).empty());
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — Export.h: No such file or directory.
- Step 3: Write
Export.h
Create Client/udpscope/Export.h:
/**
* @file Export.h
* @brief Long-format CSV export of a capture or of the visible live window
* (spec §10).
*/
#ifndef UDPSCOPE_EXPORT_H
#define UDPSCOPE_EXPORT_H
#include "PaneTree.h"
#include "Types.h"
#include <string>
#include <vector>
namespace udpscope {
/** One signal's samples, already restricted to the exported window. */
struct CsvTrace {
std::string name;
Series data;
};
/**
* @param t0 the window start; `time_s` is measured from it.
* @return the whole file, header included, ending in a newline.
*/
std::string BuildCsv(const std::vector<CsvTrace>& traces, double t0);
bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>& traces,
double t0, std::string& err);
/** Every distinct signal assigned anywhere under @a n, first use first. */
std::vector<std::string> SignalsInPane(const PaneNode* n);
} /* namespace udpscope */
#endif /* UDPSCOPE_EXPORT_H */
- Step 4: Write
Export.cpp
Create Client/udpscope/Export.cpp:
#include "Export.h"
#include <algorithm>
#include <cstdio>
#include <fstream>
namespace udpscope {
namespace {
void collect(const PaneNode* n, std::vector<std::string>& out) {
if (n == NULL) {
return;
}
if (n->leaf) {
for (size_t i = 0u; i < n->signals.size(); i++) {
const std::string& name = n->signals[i].signalName;
if (std::find(out.begin(), out.end(), name) == out.end()) {
out.push_back(name);
}
}
return;
}
collect(n->a.get(), out);
collect(n->b.get(), out);
}
} /* namespace */
std::vector<std::string> SignalsInPane(const PaneNode* n) {
std::vector<std::string> out;
collect(n, out);
return out;
}
std::string BuildCsv(const std::vector<CsvTrace>& traces, double t0) {
std::string out = "signal,time_s,wallclock_s,value\n";
char buf[320];
for (size_t i = 0u; i < traces.size(); i++) {
const CsvTrace& c = traces[i];
const size_t n = std::min(c.data.t.size(), c.data.v.size());
for (size_t k = 0u; k < n; k++) {
std::snprintf(buf, sizeof(buf), "%s,%.9f,%.6f,%.10g\n",
c.name.c_str(), c.data.t[k] - t0, c.data.t[k],
c.data.v[k]);
out += buf;
}
}
return out;
}
bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>& traces,
double t0, std::string& err) {
std::ofstream f(path.c_str(), std::ios::trunc);
if (!f) {
err = "cannot write " + path;
return false;
}
f << BuildCsv(traces, t0);
if (!f) {
err = "write failed for " + path;
return false;
}
return true;
}
} /* namespace udpscope */
Add Export.cpp to CORE_SOURCES.
- Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='BuildCsv*:ExportCsvFile*:SignalsInPane*'
Expected: PASS, 10 tests.
- Step 6: Commit the export core
git add Client/udpscope/Export.h Client/udpscope/Export.cpp \
Client/udpscope/tests/ExportTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): long-format CSV export"
- Step 7: Gather the traces and hook up the File menu
In App.h, add #include "Export.h" and:
/**
* @param node the subtree to export, or the whole tree when null.
* @return false with @a err set when there is nothing to export.
*/
bool gatherExport(const PaneNode* node, std::vector<CsvTrace>& out,
double& t0, std::string& err) const;
void exportCsv(const PaneNode* node);
std::string exportDir_; /**< where the last export went */
PaneNode* exportPane_ = NULL; /**< set by the pane context menu */
In App.cpp:
bool App::gatherExport(const PaneNode* node, std::vector<CsvTrace>& out,
double& t0, std::string& err) const {
const std::vector<std::string> names =
SignalsInPane(node != NULL ? node : tree_.root());
if (names.empty()) {
err = "nothing to export: no signals are assigned";
return false;
}
const bool haveCapture = latch_.showing();
/* A capture is exported whole; live data is exported over exactly the
range on screen, which is what the user is looking at. */
const double x0 = haveCapture ? latch_.x0() : xaxis_.x0();
const double x1 = haveCapture ? latch_.x1() : xaxis_.x1();
t0 = x0;
for (size_t i = 0u; i < names.size(); i++) {
TraceData d;
bool ok = false;
if (haveCapture) {
/* maxPoints is the raw count here: the export must not be
decimated, and FetchCaptureTrace fills raw regardless. */
ok = FetchCaptureTrace(latch_.capture(), names[i], x0, x1,
opt_.maxPlotPoints, d);
} else {
ok = FetchLiveTrace(store_, names[i], x0, x1, opt_.maxPlotPoints, d);
}
if (!ok || d.raw.empty()) {
continue;
}
CsvTrace c;
c.name = names[i];
c.data = d.raw;
out.push_back(c);
}
if (out.empty()) {
err = "nothing to export: no samples in the visible range";
return false;
}
return true;
}
void App::exportCsv(const PaneNode* node) {
std::vector<CsvTrace> traces;
double t0 = 0.0;
std::string err;
if (!gatherExport(node, traces, t0, err)) {
status_ = err;
return;
}
/* No file dialog: one fewer dependency, and a timestamped name in the
working directory is what a bench capture wants anyway. */
char name[128];
std::snprintf(name, sizeof(name), "udpscope-%lld.csv",
static_cast<long long>(std::time(NULL)));
const std::string path = exportDir_.empty() ? std::string(name)
: exportDir_ + "/" + name;
if (ExportCsvFile(path, traces, t0, err)) {
status_ = "exported " + path;
} else {
status_ = err;
}
}
App.cpp needs #include <ctime> for the file name stamp.
Add to the File menu, above Save Layout:
if (ImGui::MenuItem("Export CSV (all panes)")) {
exportCsv(NULL);
}
if (ImGui::MenuItem("Export CSV (this pane)", NULL, false,
exportPane_ != NULL)) {
exportCsv(exportPane_);
}
ImGui::Separator();
exportPane_ is the pane the pointer was last over. In drawPlotArea(),
after paneView_.drawTree(...), add:
exportPane_ = ctx.hoveredLeaf;
with PaneNode* hoveredLeaf = nullptr; added to PaneContext, set in
PaneView::drawLeaf() right after ImPlot::BeginPlot() succeeds:
if (ImPlot::IsPlotHovered()) {
ctx.hoveredLeaf = &leaf;
}
and cleared by PaneView::drawTree() before it walks the layout:
ctx.hoveredLeaf = nullptr;
- Step 8: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–16.
Against the demo streamer:
- Two panes with one signal each, live. File → Export CSV (all panes)
writes
udpscope-<unix>.csvin the working directory and the status bar names it. head -3shows the header and two rows whosetime_sstarts near 0 and whosewallclock_sis the current Unix time.cut -d, -f1 file.csv | sort -ulists exactly the two signals.- Arm the trigger, wait for a capture, export again: the row count matches
window × ratefor each signal, andtime_sspans the window. - Hover one pane, File → Export CSV (this pane): only that pane's signals are in the file.
- Step 9: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp
git commit -m "feat(udpscope): export the capture or the live window to CSV"
Task 17: Profile panes and the array-interpretation toggle
Spec §4.2 says a signal that is genuinely a vector is plotted against element index, not unrolled onto the time axis. Tasks 6–8 already store and override those signals; nothing draws them yet and nothing lets the user flip the interpretation. This closes both.
Files:
- Modify:
Client/udpscope/PlotData.h - Modify:
Client/udpscope/PlotData.cpp - Modify:
Client/udpscope/PaneView.cpp - Modify:
Client/udpscope/SignalList.cpp - Modify:
Client/udpscope/tests/PlotDataTest.cpp
Interfaces:
- Consumes:
SignalStore::readProfile,Profile(Task 6);Receiver::setProfileOverride/profileOverride(Task 8);SignalMeta::isVectorProfile()(Task 4);PaneNode::profilePane(Task 2);TraceData(Task 10). - Produces:
bool FetchProfileTrace(const SignalStore&, const std::string&, TraceData&, double& stamp).
Why a profile gets a whole pane rather than sharing one: its X axis is
element index, not seconds. Mixing it with a time trace in the same pane would
put two incompatible units on one axis. PaneNode::profilePane already exists
for exactly this, and PaneTree already propagates it through splits and
closes; this task is the first code that reads it.
- Step 1: Write the failing tests
Append to Client/udpscope/tests/PlotDataTest.cpp:
TEST(FetchProfileTrace, PlotsTheVectorAgainstElementIndex) {
SignalStore store;
std::vector<SignalMeta> metas;
SignalMeta m = scalar("vec");
m.numCols = 4;
metas.push_back(m);
store.setSignals(metas);
const double vals[4] = {10.0, 20.0, 30.0, 40.0};
store.pushProfile("vec", 1234.5, vals, 4u);
TraceData d;
double stamp = 0.0;
ASSERT_TRUE(FetchProfileTrace(store, "vec", d, stamp));
EXPECT_TRUE(d.found);
EXPECT_DOUBLE_EQ(stamp, 1234.5);
ASSERT_EQ(d.raw.size(), 4u);
EXPECT_DOUBLE_EQ(d.raw.t[0], 0.0);
EXPECT_DOUBLE_EQ(d.raw.t[3], 3.0);
EXPECT_DOUBLE_EQ(d.raw.v[2], 30.0);
/* A profile is one screenful of points; it is never decimated. */
EXPECT_EQ(d.draw.size(), 4u);
}
TEST(FetchProfileTrace, FailsForASignalThatIsNotAProfile) {
SignalStore store;
std::vector<SignalMeta> metas;
metas.push_back(scalar("plain"));
store.setSignals(metas);
const double t[1] = {1.0};
const double v[1] = {2.0};
store.push("plain", t, v, 1u);
TraceData d;
double stamp = 0.0;
EXPECT_FALSE(FetchProfileTrace(store, "plain", d, stamp));
EXPECT_FALSE(FetchProfileTrace(store, "absent", d, stamp));
}
TEST(FetchProfileTrace, AnEmptyProfileIsNotDrawable) {
SignalStore store;
std::vector<SignalMeta> metas;
SignalMeta m = scalar("vec");
m.numCols = 4;
metas.push_back(m);
store.setSignals(metas);
TraceData d;
double stamp = 0.0;
EXPECT_FALSE(FetchProfileTrace(store, "vec", d, stamp));
}
- Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
Expected: FAIL — 'FetchProfileTrace' was not declared in this scope.
- Step 3: Implement
FetchProfileTrace
Add to Client/udpscope/PlotData.h, beside the other fetchers:
/**
* @brief Reads the latest vector snapshot, plotted against element index.
* @param stamp receives the wall-clock time the snapshot was taken.
* @return false when the signal has no profile snapshot.
*/
bool FetchProfileTrace(const SignalStore& store, const std::string& name,
TraceData& out, double& stamp);
and to Client/udpscope/PlotData.cpp:
bool FetchProfileTrace(const SignalStore& store, const std::string& name,
TraceData& out, double& stamp) {
out.raw.clear();
out.draw.clear();
out.found = false;
Profile p;
if (!store.readProfile(name, p) || p.v.empty()) {
return false;
}
out.found = true;
stamp = p.time;
out.raw.t = p.x;
out.raw.v = p.v;
/* Element count is bounded by the array size, not by a sample rate, so
there is nothing to decimate. */
out.draw = out.raw;
return true;
}
- Step 4: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FetchProfileTrace*'
Expected: PASS, 3 tests.
- Step 5: Draw profile panes
In Client/udpscope/PaneView.cpp, at the top of drawLeaf()'s plot body,
branch on the pane kind. Replace the ImPlot::SetupAxes("t [s]", "div") call
and the trace loop's entry condition with:
if (leaf.profilePane) {
ImPlot::SetupAxes("element", "value");
ImPlot::SetupAxisLimits(ImAxis_X1, 0.0, 1.0, ImPlotCond_Once);
ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Once);
for (size_t i = 0u; i < leaf.signals.size(); i++) {
Assignment& a = leaf.signals[i];
TraceData d;
double stamp = 0.0;
if (ctx.store == nullptr ||
!FetchProfileTrace(*ctx.store, a.signalName, d, stamp) ||
d.draw.empty()) {
continue;
}
char plabel[128];
std::snprintf(plabel, sizeof(plabel), "%s @%.3f s",
a.signalName.c_str(), stamp);
ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
ImPlot::PlotLine(plabel, d.draw.t.data(), d.draw.v.data(),
static_cast<int>(d.draw.size()));
}
ImPlot::EndPlot();
return; /* no divisions, no cursors, no shared X axis here */
}
A profile pane keeps ImPlot's own auto-fit (ImPlotCond_Once plus the user's
zoom), because it has no time axis to share and no division model to obey.
Assigning a signal to a pane decides what kind of pane it is. In the
drag-and-drop accept block, replace the plain leaf.signals.push_back(a);
with:
const SignalMeta& dm = ctx.metaFor(dropped);
const bool wantsProfile = dm.isVectorProfile();
/* Not a `return`: the drop target sits between BeginPlot() and
EndPlot(), so bailing out here would unbalance ImPlot. */
if (leaf.signals.empty() || leaf.profilePane == wantsProfile) {
leaf.profilePane = wantsProfile;
leaf.signals.push_back(a);
}
- Step 6: Add the interpretation toggle to the signal list
In Client/udpscope/SignalList.cpp, inside the per-signal loop after the
tooltip, add:
if (m.numElements() > 1u && m.timeMode == kTimePacket) {
/* Only PACKET arrays are ambiguous: everything else says outright
whether it is a burst. */
if (ImGui::BeginPopupContextItem("##sigmenu")) {
bool prof = rx_.profileOverride(m.name);
if (ImGui::MenuItem("plot against element index", NULL, &prof)) {
rx_.setProfileOverride(m.name, prof);
status_ = m.name + (prof ? ": vector profile"
: ": packed burst");
}
ImGui::EndPopup();
}
}
The override reaches the receiver thread through Receiver's command queue
and survives a CONFIG re-send (Task 8), so a stream that re-announces itself
does not silently revert to burst.
SignalList.cpp needs #include "Types.h" for kTimePacket; App.h already
pulls it in through SignalStore.h.
- Step 7: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
Expected: PASS, all tests from Tasks 1–17.
Against the demo streamer, whose SineArrayGAM produces a packed array:
- Drop the array signal into a pane: it is unrolled onto the time axis and looks like a continuous sine, which is the correct default.
- Right-click it in the signal list, tick plot against element index: the
status bar confirms
vector profile. - Drop it into an empty pane: the X axis now reads
element, the trace has exactlyNumberOfElementspoints, and the legend shows the snapshot time ticking forward. - Try to drop a scalar into that same pane: the drop is refused, because the axes are incompatible.
- Untick the override: dropping it into a fresh pane gives a time trace again.
- Step 8: Commit
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
Client/udpscope/PaneView.cpp Client/udpscope/SignalList.cpp \
Client/udpscope/tests/PlotDataTest.cpp
git commit -m "feat(udpscope): index-plot vector profiles and toggle the array interpretation"
Task 18: Documentation and repository integration
Files:
- Create:
Docs/UDPScope.md - Create:
Client/udpscope/resources/udpscope.desktop - Create:
Client/udpscope/resources/icons/udpscope.svg - Modify:
Client/udpscope/CMakeLists.txt - Modify:
README.md - Modify:
CLAUDE.md - Modify:
ARCHITECTURE.md - Modify:
.gitignore
Interfaces:
-
Consumes: everything built in Tasks 1–17. No new code.
-
Step 1: Write
Docs/UDPScope.md
Create Docs/UDPScope.md:
# UDPScope
A bench oscilloscope that attaches directly to one `UDPStreamer`. No StreamHub,
no WebSocket, no browser: the UDPS datagrams go straight into the scope through
the standalone C client in `Common/Client/c`.
Use it when you want to look at a signal now — on a control-room machine, over
a lab network, on a host that has nothing installed. Use StreamHub instead when
you need several sources aggregated, history on disk, or more than one client
watching at once.
## Build
Needs SDL2 and OpenGL; ImGui, ImPlot and GoogleTest are fetched by CMake.
```bash
cd Client/udpscope
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/udpscope_tests # unit tests
```
## Run
```bash
./build/UDPScope --host 192.168.1.10 --port 44500
```
| Option | Default | Meaning |
|---|---|---|
| `--host ADDR` | `127.0.0.1` | Streamer control address |
| `--port N` | `44500` | Streamer control port |
| `--multicast GROUP` | — | Join this group instead of unicast |
| `--iface ADDR` | — | Local interface IP for the multicast join |
| `--data-port N` | `0` | Data port when the streamer separates it |
| `--silence SEC` | `2.0` | Reconnect after this long without a packet |
| `--config PATH` | `$XDG_CONFIG_HOME/udpscope/session.conf` | Session file |
| `--max-mpts N` | `4000` | Drawn points per trace before decimation |
Long `--` options only, matching `Common/Client/c/example/udps_dump.c`. An
option given on the command line beats the session file; anything omitted comes
from the file.
## Panes
The plot area is a splittable grid. Hover a pane to reveal its handles:
- the four edge handles split it left/right/top/bottom;
- the ✕ closes it and gives its space back to its sibling;
- the border between two panes is a splitter you can drag.
Drag a signal from the list on the left into a pane to plot it. Right-click a
legend entry for colour, line width and vertical scale; the same menu removes
the trace.
## Vertical scale
Panes are eight divisions tall, ±4 about the centre line, so traces in
different units share a pane without lying about each other's amplitude. Each
trace has its own volts-per-division:
- **auto** — fits the samples currently on screen;
- **range** — uses the `range_min`/`range_max` the CONFIG packet carries;
- **manual** — you set per-division and offset, as on a bench scope.
The legend shows the value per division for each trace.
## Time axis
Every pane shares one X axis. In live mode it follows the newest sample; any
pan or zoom detaches it, and View → Live re-attaches. View → window sets the
span in seconds.
## Trigger
The trigger bar configures a client-side trigger: signal, edge, threshold,
hysteresis, window length, and where in that window the trigger point sits
(`pre %`).
| Mode | Behaviour |
|---|---|
| **Norm** | Re-arms after every capture; the display holds the last one until the next trigger |
| **1x** | Captures once and stays held until you press Re-arm |
The badge reads `IDLE`, `ARMED nn%`, `TRIG'D` or `HELD`. The percentage is the
pre-trigger window filling up: the scope refuses to arm until it holds enough
history to back-fill the part of the capture that precedes the trigger, so a
capture never starts mid-waveform.
Untick **follow** to study one capture while later ones go by; tick it again
and the newest capture appears at once. **Live** drops the capture and goes
back to the rolling display.
Known simplification: with `edge = both` and a non-zero hysteresis, re-arming
uses the rising-edge arm level for both directions. Set hysteresis to 0 if you
need symmetric behaviour on a noisy bipolar signal.
## Cursors and measurements
View → Cursors puts two draggable time cursors in every pane at once; the
status bar reads `A`, `B`, `dt` and `1/dt`. View → Measurements overlays
min/max/peak-to-peak/average/RMS per trace, taken between the cursors when they
are up and over the visible range otherwise.
All statistics come from the undecimated samples, never from the drawn
envelope, so a single-sample spike is counted even when it is not individually
visible.
## Array signals
A signal with `NumberOfElements > 1` is a packed burst by default: its elements
are unrolled onto the time axis using the accompanying time signal or the
declared sampling rate. A signal that is genuinely a vector — a spatial
profile, not a burst — is plotted against element index instead. Toggle the
interpretation from the signal list's context menu; the choice survives a
CONFIG re-send.
## Export
File → Export CSV writes long format:
```
signal,time_s,wallclock_s,value
Voltage,0.000000000,1756291200.123456,0.4981
```
`time_s` is measured from the start of the exported window; `wallclock_s` is
absolute. One row per sample per signal, because signals carry independent
timestamps and a wide format would need resampling. Export covers every
assigned signal, or just the pane under the pointer.
## Session file
Layout, colours, vertical scales, trigger settings, cursors and the source are
saved on exit and from File → Save Layout, to
`$XDG_CONFIG_HOME/udpscope/session.conf`. The format is line-based and
hand-editable; indentation is decorative. A malformed file is reported in the
status bar and ignored outright, never partially applied.
## Diagnostics
The status bar counts packets, frames, counter gaps, dropped fragments and
reconnects; the last three turn red as soon as they are non-zero.
`udpscope_rxprobe` is a headless build of the same receive path. When the GUI
shows nothing, run it to find out whether the problem is the network or the
scope:
```bash
./build/udpscope_rxprobe --host 192.168.1.10 --port 44500
```
Cross-check against the reference C client if they disagree:
```bash
cd Common/Client/c && make && ./udps_dump 192.168.1.10 44500
```
## Relationship to StreamHub
| | UDPScope | StreamHub clients |
|---|---|---|
| Sources | One streamer | Many, aggregated |
| Transport | UDPS direct | UDPS → hub → WebSocket |
| History | In-memory rings only | Disk-backed `.shist` files |
| Trigger | In the client | In the hub, shared by clients |
| Processes | One | Hub plus client |
The two share the wire format (`Common/UDP/UDPSProtocol.h`) and nothing else.
UDPScope reads `Client/streamhub/SignalBuffer.h` and the shared fonts
read-only; it never modifies anything under `Client/streamhub/`.
- Step 1b: Add the desktop entry and icon
Spec §12 asks for install rules covering the .desktop entry and icon, which
Tasks 1–17 left out because there was nothing to install them from. UDPScope
needs its own rather than reusing Client/streamhub/resources/, which is
read-only to this project.
Create Client/udpscope/resources/udpscope.desktop:
[Desktop Entry]
Type=Application
Name=UDPScope
GenericName=Signal Oscilloscope
Comment=Bench oscilloscope attached directly to a MARTe2 UDPStreamer
Exec=UDPScope
Icon=udpscope
Terminal=false
Categories=Science;Engineering;DataVisualization;
Keywords=MARTe2;oscilloscope;UDPS;signals;
StartupWMClass=UDPScope
Create Client/udpscope/resources/icons/udpscope.svg — a trace on a graticule,
in the same Catppuccin palette the app uses:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="2" y="2" width="60" height="60" rx="8" fill="#1e1e2e"/>
<g stroke="#45475a" stroke-width="1">
<path d="M2 17h60M2 32h60M2 47h60M17 2v60M32 2v60M47 2v60"/>
</g>
<path d="M4 32 C 12 4, 20 60, 32 32 S 52 4, 60 32"
fill="none" stroke="#a6e3a1" stroke-width="3" stroke-linecap="round"/>
<circle cx="32" cy="32" r="3" fill="#fab387"/>
</svg>
Extend the install block in Client/udpscope/CMakeLists.txt:
install(FILES ${RESOURCE_DIR}/udpscope.desktop
DESTINATION share/applications)
install(FILES ${RESOURCE_DIR}/icons/udpscope.svg
DESTINATION share/icons/hicolor/scalable/apps)
RESOURCE_DIR points at Client/streamhub/resources (Task 1, for the fonts),
so add a second variable beside it and use that here:
set(UDPSCOPE_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources)
then replace ${RESOURCE_DIR} with ${UDPSCOPE_RESOURCE_DIR} in the two
install lines above.
Verify:
cd Client/udpscope && cmake -B build -DCMAKE_INSTALL_PREFIX=/tmp/udpscope-prefix \
&& cmake --build build -j && cmake --install build
find /tmp/udpscope-prefix -type f | sort
Expected: bin/UDPScope, the fonts under share/udpscope,
share/applications/udpscope.desktop and
share/icons/hicolor/scalable/apps/udpscope.svg.
- Step 2: Add the repository entries
In README.md, add to the capability table after the Integrated client row:
| **Direct scope** | `Client/udpscope` | ImGui bench oscilloscope attached straight to one UDPStreamer |
add to the repository structure block under Client/:
├── Client/udpscope/ Direct-UDPS ImGui oscilloscope (SDL2 + ImPlot)
add a component section after StreamHub Application:
### UDPScope
Single-source bench oscilloscope (`Client/udpscope/`) that decodes UDPS
datagrams directly through `Common/Client/c` — no hub, no browser, one process.
Splittable pane grid, client-side Normal/Single trigger with a pre-trigger
window, per-trace division scaling, cursors and measurements, CSV export and a
saved session layout.
```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/UDPScope --host 127.0.0.1 --port 44500
See Docs/UDPScope.md.
and add to the documentation table:
```markdown
| `Docs/UDPScope.md` | Direct-UDPS bench oscilloscope user guide |
In CLAUDE.md, add to the build block after the Qt client lines:
# Direct-UDPS ImGui bench scope (not a MARTe2 component; needs SDL2)
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/udpscope_tests
and a paragraph after the Qt client paragraph:
**UDPScope** (`Client/udpscope/`): bench oscilloscope that talks UDPS directly
to a single `UDPStreamer` through the standalone C client, bypassing StreamHub
entirely. A receiver thread owns the C client and the trigger FSM; a
mutex-guarded `SignalStore` hands data to the GUI thread. Everything except
`main.cpp`, `App.cpp` and `PaneView.cpp` is framework-free and unit-tested in
`udpscope_tests`. It consumes `Client/streamhub/SignalBuffer.h` and the shared
fonts read-only and must never modify anything under `Client/streamhub/`.
In ARCHITECTURE.md, add UDPScope as a second consumer of the streaming path:
a client that attaches to UDPStreamer directly rather than through StreamHub,
with the trigger in the client instead of the hub.
In .gitignore, add:
Client/udpscope/build/
- Step 3: Check the documentation against the build
cd Client/udpscope && rm -rf build \
&& cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j \
&& ./build/udpscope_tests
Expected: a clean tree builds from the commands as written, and every test
passes. Then walk Docs/UDPScope.md from top to bottom against the running
binary: every option in the table is accepted, every menu item named exists,
and the trigger badge shows the four documented strings.
- Step 4: Commit
git add Docs/UDPScope.md Client/udpscope/resources Client/udpscope/CMakeLists.txt \
README.md CLAUDE.md ARCHITECTURE.md .gitignore
git commit -m "docs: UDPScope bench oscilloscope"