feat(udpscope): build scaffold and min/max envelope decimation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2d62e1808b
commit
fba4360c80
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
compile_commands.json
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources)
|
||||||
|
|
||||||
|
# ── 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()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#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 */
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @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 */
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @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 */
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#include "Decimate.h"
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#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) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user