Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in
[`CLAUDE.md`](./CLAUDE.md) → "Release Versioning".

## [0.27.0]

### Feature: shared timestamp arithmetic and axis policy (MINOR)

`pj_base/time_math.hpp` adds C++17-clean, checked time arithmetic usable by parser modules,
the host, and plugins: `nanosecondsPer`, `scaleToNanoseconds`, `toSignedTicks`,
`secondsToNanoseconds`, `combineSecondsAndNanos`, `syntheticInstant`, and
`fitSyntheticInterval`. The absolute-time spine re-exports this arithmetic through
`pj_base/time.hpp`.

Layered on that spine, `pj_plugins/sdk/timestamp_policy.hpp` is meant to replace the five
divergent per-plugin timestamp-axis detectors inventoried in
[#186](https://github.com/PlotJuggler/plotjuggler_sdk/issues/186) with one header-only
detection and configuration contract: native timestamp storage first, then canonical names
restricted to eligible scalar storage, never expanded list elements. `PJ::sdk::timestampEligibility`
judges storage against the configured `timestamp_unit`: 64-bit integers, native timestamps and
`double` are always eligible, 32-bit integers only when the unit is seconds, and 8/16-bit integers
and `float32` are explicit-only. Explicitly selected explicit-only storage carries a shared warning, while canonical axis configuration keys and `PJ::TimeUnit` stop unit inference from
being private plugin policy. `PJ::sdk::timestampNamePriority` exposes the allocation-free name pass
beside `PJ::sdk::detectTimestampColumn`. No ABI change; `abi/baseline.abi` untouched.

## [0.26.0]

### Feature: `GridMap` canonical builtin object (MINOR)
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ not in the PJ4 superproject. This file is the root navigation node for the whole
standalone C++17 functional parser-module authoring kit (`pj_base/parser_module/`), the host-side
wasm parser-module manifest custom-section codec, and the test-only static WASI ABI auditor. The
0.22 authoring helper builds native parser modules only; wasm loading/execution is not present.
The absolute-time spine now also carries checked arithmetic shared across those layers.
- **descriptor_import_support** — a separate compiled component
(`plotjuggler_sdk::descriptor_import_support`, headers under
`pj_base/sdk/descriptor_import/`): the callee side of the descriptor-import
Expand All @@ -32,7 +33,9 @@ not in the PJ4 superproject. This file is the root navigation node for the whole
- **pj_plugins** — host-side loaders + RAII handles + plugin **discovery** (directory scan +
embedded-manifest inspection) for four plugin families (DataSource, MessageParser, Dialog, Toolbox),
parser claim admission/resolution and native functional parser-module execution,
config-envelope helpers, and the **dialog C ABI** (`pj_plugins/dialog_protocol/`). The
config-envelope helpers, shared plugin-authoring policies
(`pj_plugins/sdk/parser_array_policy.hpp`, `pj_plugins/sdk/timestamp_policy.hpp`), and the
**dialog C ABI** (`pj_plugins/dialog_protocol/`). The
duplicate-resolution *catalog* (which copy wins by priority/version/compatibility) is host policy
and lives in the app (`pj_runtime`), built on these discovery primitives. Note the split: the DataSource/MessageParser/Toolbox C-ABI
protocol headers live in `pj_base`; the **Dialog** protocol header lives here, not in `pj_base`.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.26.0
0.27.0
1 change: 1 addition & 0 deletions pj_base/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ if(PJ_BUILD_TESTS)
-Wl,--export=pj_module_free
-o "${_pj_wasm_raw}"
DEPENDS "${_pj_wasm_source}" ${_pj_parser_module_headers}
"${CMAKE_CURRENT_SOURCE_DIR}/include/pj_base/time_math.hpp"
COMMENT "Compiling C++17 parser-module WASI reactor fixture"
VERBATIM
)
Expand Down
12 changes: 5 additions & 7 deletions pj_base/include/pj_base/parser_module/time.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,22 @@
/** @file time.hpp @brief Checked ROS and protobuf timestamp normalization. */

#include <cstdint>
#include <limits>

#include "pj_base/parser_module/core.hpp"
#include "pj_base/time_math.hpp"

namespace pj {
namespace detail {

inline Expected<int64_t> combineSecondsAndNanos(int64_t seconds, int32_t nanos) {
constexpr int64_t kNanosPerSecond = INT64_C(1000000000);
if (nanos < 0 || nanos >= kNanosPerSecond) {
const auto combined = PJ::combineSecondsAndNanos(seconds, nanos);
if (nanos < 0 || nanos >= INT64_C(1000000000)) {
return Status::error("timestamp nanoseconds are outside [0, 1000000000)");
}
const int64_t positive_room = (std::numeric_limits<int64_t>::max() - nanos) / kNanosPerSecond;
const int64_t negative_room = std::numeric_limits<int64_t>::min() / kNanosPerSecond;
if (seconds > positive_room || seconds < negative_room) {
if (!combined) {
return Status::error("timestamp is outside the int64 nanosecond range");
}
return seconds * kNanosPerSecond + nanos;
return *combined;
}

} // namespace detail
Expand Down
3 changes: 2 additions & 1 deletion pj_base/include/pj_base/time.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

#include <chrono>

#include "pj_base/types.hpp" // PJ::Timestamp, PJ::Range
#include "pj_base/time_math.hpp" // Checked arithmetic shared with C++17 parser modules.
#include "pj_base/types.hpp" // PJ::Timestamp, PJ::Range

namespace PJ {

Expand Down
132 changes: 132 additions & 0 deletions pj_base/include/pj_base/time_math.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2026 Davide Faconti
// SPDX-License-Identifier: Apache-2.0

#pragma once

/** @file time_math.hpp @brief C++17 checked arithmetic for nanosecond timestamps. */

#include <cmath>
#include <cstdint>
#include <limits>
#include <optional>

namespace PJ {

/// Units a tick count can be expressed in. Nanoseconds is the spine's native unit.
enum class TimeUnit : uint8_t { kSeconds, kMilliseconds, kMicroseconds, kNanoseconds };

/// Returns the integral nanosecond scale for a time unit.
[[nodiscard]] constexpr int64_t nanosecondsPer(TimeUnit unit) noexcept {
switch (unit) {
case TimeUnit::kSeconds:
return 1'000'000'000;
case TimeUnit::kMilliseconds:
return 1'000'000;
case TimeUnit::kMicroseconds:
return 1'000;
case TimeUnit::kNanoseconds:
return 1;
}
return 0;
}

/// Converts ticks in the supplied unit to nanoseconds, returning nullopt on overflow.
[[nodiscard]] constexpr std::optional<int64_t> scaleToNanoseconds(int64_t ticks, TimeUnit unit) noexcept {
const int64_t scale = nanosecondsPer(unit);
if (scale == 0 || ticks > std::numeric_limits<int64_t>::max() / scale ||
ticks < std::numeric_limits<int64_t>::min() / scale) {
return std::nullopt;
}
return ticks * scale;
}

/// Checked uint64 -> int64 tick conversion; values above INT64_MAX return nullopt.
[[nodiscard]] constexpr std::optional<int64_t> toSignedTicks(uint64_t ticks) noexcept {
if (ticks > static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
return std::nullopt;
}
return static_cast<int64_t>(ticks);
}

/// Converts floating seconds to nanoseconds using an integer split and half-away-from-zero rounding.
/// Non-finite inputs and either whole-second or final-addition overflow return nullopt.
[[nodiscard]] inline std::optional<int64_t> secondsToNanoseconds(double seconds) noexcept {
if (!std::isfinite(seconds)) {
return std::nullopt;
}

constexpr int64_t kNanosecondsPerSecond = 1'000'000'000;
constexpr int64_t kMaximumWholeSeconds = std::numeric_limits<int64_t>::max() / kNanosecondsPerSecond;
constexpr int64_t kMinimumWholeSeconds = std::numeric_limits<int64_t>::min() / kNanosecondsPerSecond;

double whole_seconds = 0.0;
const double fractional_seconds = std::modf(seconds, &whole_seconds);
if (whole_seconds > static_cast<double>(kMaximumWholeSeconds) ||
whole_seconds < static_cast<double>(kMinimumWholeSeconds)) {
return std::nullopt;
}

const int64_t whole_nanoseconds = static_cast<int64_t>(whole_seconds) * kNanosecondsPerSecond;
const int64_t fractional_nanoseconds =
static_cast<int64_t>(std::llround(fractional_seconds * static_cast<double>(kNanosecondsPerSecond)));
if ((fractional_nanoseconds > 0 &&
whole_nanoseconds > std::numeric_limits<int64_t>::max() - fractional_nanoseconds) ||
(fractional_nanoseconds < 0 &&
whole_nanoseconds < std::numeric_limits<int64_t>::min() - fractional_nanoseconds)) {
return std::nullopt;
}
return whole_nanoseconds + fractional_nanoseconds;
}

/// Combines seconds and nanoseconds-of-second; nanos must be in [0, 1e9).
/// Returns nullopt when either the nanos range or the signed timestamp range would be exceeded.
[[nodiscard]] constexpr std::optional<int64_t> combineSecondsAndNanos(int64_t seconds, int64_t nanos) noexcept {
constexpr int64_t kNanosecondsPerSecond = 1'000'000'000;
if (nanos < 0 || nanos >= kNanosecondsPerSecond) {
return std::nullopt;
}
const int64_t positive_room = (std::numeric_limits<int64_t>::max() - nanos) / kNanosecondsPerSecond;
const int64_t negative_room = std::numeric_limits<int64_t>::min() / kNanosecondsPerSecond;
if (seconds > positive_room || seconds < negative_room) {
return std::nullopt;
}
return seconds * kNanosecondsPerSecond + nanos;
}

/// Computes anchor + row * interval for a non-negative synthetic row, returning nullopt on overflow.
[[nodiscard]] constexpr std::optional<int64_t> syntheticInstant(
int64_t anchor_ns, int64_t interval_ns, int64_t row) noexcept {
if (row < 0) {
return std::nullopt;
}
if ((interval_ns > 0 && row > std::numeric_limits<int64_t>::max() / interval_ns) ||
(interval_ns < 0 && row > 0 && interval_ns < std::numeric_limits<int64_t>::min() / row)) {
return std::nullopt;
}
const int64_t offset = interval_ns * row;
if ((offset > 0 && anchor_ns > std::numeric_limits<int64_t>::max() - offset) ||
(offset < 0 && anchor_ns < std::numeric_limits<int64_t>::min() - offset)) {
return std::nullopt;
}
return anchor_ns + offset;
}

/// Fits an interval across rows over [first, last], using fallback for insufficient rows,
/// a non-positive span, or an interval that cannot be represented by int64_t.
[[nodiscard]] constexpr int64_t fitSyntheticInterval(
int64_t first_ns, int64_t last_ns, int64_t rows, int64_t fallback_ns) noexcept {
if (rows < 2 || last_ns <= first_ns) {
return fallback_ns;
}
const uint64_t span = static_cast<uint64_t>(last_ns) - static_cast<uint64_t>(first_ns);
const uint64_t interval = span / static_cast<uint64_t>(rows - 1);
if (interval > static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
return fallback_ns;
}
return static_cast<int64_t>(interval);
}

/// Default cadence for a synthesized axis when nothing better is known (approximately 30 fps).
inline constexpr int64_t kDefaultSyntheticIntervalNs = 33'333'333;

} // namespace PJ
60 changes: 60 additions & 0 deletions pj_base/tests/time_spine_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

#include <gtest/gtest.h>

#include <cstdint>
#include <limits>
#include <optional>
#include <type_traits>

#include "pj_base/time.hpp"
Expand Down Expand Up @@ -39,4 +42,61 @@ TEST(TimeSpine, FromRawRangeLiftsBothEnds) {
EXPECT_EQ(PJ::toRaw(lifted.max), 5'000'000'000LL);
}

TEST(TimeMath, ScalesTicksAndRejectsOverflow) {
EXPECT_EQ(PJ::scaleToNanoseconds(1, PJ::TimeUnit::kSeconds), std::optional<int64_t>{1'000'000'000});
EXPECT_FALSE(PJ::scaleToNanoseconds(9'223'372'037, PJ::TimeUnit::kSeconds));
}

TEST(TimeMath, ConvertsUnsignedTicksWithinSignedRange) {
EXPECT_FALSE(PJ::toSignedTicks(std::numeric_limits<uint64_t>::max()));
EXPECT_EQ(
PJ::toSignedTicks(static_cast<uint64_t>(std::numeric_limits<int64_t>::max())),
std::optional<int64_t>{std::numeric_limits<int64_t>::max()});
}

TEST(TimeMath, ConvertsSecondsUsingIntegerSplitAndStableRounding) {
struct ConversionCase {
double seconds;
int64_t nanoseconds;
};
const ConversionCase cases[] = {
{1.5, 1'500'000'000}, {1.7e9 + 0.125, 1'700'000'000'125'000'000}, {-1.6e-9, -2}, {1.6e-9, 2}, {2.4e-9, 2},
};

for (const ConversionCase& test_case : cases) {
const auto converted = PJ::secondsToNanoseconds(test_case.seconds);
ASSERT_TRUE(converted);
EXPECT_EQ(*converted, test_case.nanoseconds);
}
}

TEST(TimeMath, RejectsNonFiniteAndOverflowingSeconds) {
EXPECT_FALSE(PJ::secondsToNanoseconds(std::numeric_limits<double>::quiet_NaN()));
EXPECT_FALSE(PJ::secondsToNanoseconds(std::numeric_limits<double>::infinity()));
EXPECT_FALSE(PJ::secondsToNanoseconds(-std::numeric_limits<double>::infinity()));
EXPECT_FALSE(PJ::secondsToNanoseconds(9.3e9));
EXPECT_FALSE(PJ::secondsToNanoseconds(9223372036.0 + 0.999999999));
}

TEST(TimeMath, CombinesSecondsAndNanosecondsWithChecks) {
EXPECT_EQ(PJ::combineSecondsAndNanos(1, 5), std::optional<int64_t>{1'000'000'005});
EXPECT_FALSE(PJ::combineSecondsAndNanos(0, 1'000'000'000));
EXPECT_FALSE(PJ::combineSecondsAndNanos(0, -1));
}

TEST(TimeMath, ComputesSyntheticInstantsWithChecks) {
EXPECT_EQ(PJ::syntheticInstant(10, 3, 4), std::optional<int64_t>{22});
EXPECT_FALSE(PJ::syntheticInstant(std::numeric_limits<int64_t>::max(), 1, 1));
EXPECT_FALSE(PJ::syntheticInstant(std::numeric_limits<int64_t>::min(), -1, 1));
EXPECT_FALSE(PJ::syntheticInstant(10, 3, -1));
}

TEST(TimeMath, FitsSyntheticIntervalsOrUsesFallback) {
EXPECT_EQ(PJ::fitSyntheticInterval(1000, 4000, 5, 7), 750);
EXPECT_EQ(PJ::fitSyntheticInterval(1000, 1000, 5, 7), 7);
EXPECT_EQ(PJ::fitSyntheticInterval(1000, 4000, 1, 7), 7);
EXPECT_EQ(PJ::fitSyntheticInterval(4000, 1000, 5, 7), 7);
EXPECT_EQ(PJ::kDefaultSyntheticIntervalNs, 33'333'333);
}

} // namespace
Loading
Loading