Specialized numeric formats for constrained systems without an FPU or with limited bandwidth and memory. They are used across the Æthernet C++ client to represent durations, counters, sizes, fixed-point values, and compact wire codes.
- Overview
- TieredInt
- FixedPoint
- Exponential
- Text IO
- Ostream IO
- Wire IO
- Combined Types
- Integration Notes
- Running Tests
The Æthernet C++ client targets devices where every byte and every CPU cycle matters. The numeric types in this repository are header-only, compile-time configured, and deterministic. They separate two concerns:
- logical value — what the application means: milliseconds, bytes, ratios, sensor values;
- wire/storage representation — how many bytes are needed to store or transmit that value.
The core types are:
TieredInt— compact integer serialization with compile-time tier boundaries;FixedPoint— binary-scaled fixed point over an integral or packed integral representation;Exponential— logarithmic code mapping for values that span several orders of magnitude.
TieredInt stores small integer values in fewer bytes while still supporting much larger values.
Compression and decompression happen only during serialization/deserialization; normal arithmetic uses the logical integer value.
using SmallCounter = ae::TieredInt<std::uint8_t, 254>;
using SizeBytes = ae::TieredInt<std::uint8_t, 250, 1500>;
using SignedSmall = ae::TieredInt<std::int8_t, 10, 20>;The first template parameter is an integral cell type. It defines the minimum serialized chunk size, not merely a C++ value type.
For std::uint8_t, the first serialized chunk is 1 byte; for std::uint16_t, it is 2 bytes; for std::uint32_t, it is 4 bytes.
Each next tier appends a chunk whose size is the sum of the previous chunks. Therefore total serialized sizes grow as:
std::uint8_tcell: 1, 2, 4, then 8 bytes;std::uint16_tcell: 2, 4, then 8 bytes;std::uint32_tcell: 4, then 8 bytes.
The remaining template parameters are maximum logical values for each compact tier:
using SizeBytes = ae::TieredInt<std::uint8_t, 250, 1500>;For this type:
- values
0..250serialize to 1 byte; - values
251..1500serialize to 2 bytes; - values
1501..1967580serialize to 4 bytes.
This is useful for packet or payload sizes: small control payloads use one byte, normal MTU-sized values up to 1500 bytes use two bytes, and rare large values still fit up to 1,967,580 bytes, about 1.88 MiB.
For signed TieredInt, tier boundaries are validated in wire space after ZigZag encoding.
TieredInt needs extension header space. For a std::uint8_t cell, the first tier max must be below 255, so 254 is valid and 255 is not:
using Good = ae::TieredInt<std::uint8_t, 254>;
// Invalid: no extension header left.
using Bad = ae::TieredInt<std::uint8_t, 255>;FixedPoint<Rep, Max> represents a logical value as:
logical_value = raw_value * 2^kScaleExp
Rep is the raw storage type: a built-in integral type or an integer-like packed type such as TieredInt.
Max is the required logical range bound. The implementation chooses the most precise binary scale that still covers Max.
The binary point is not constrained to sit inside the bit width of Rep. It can be far to the right or far to the left of the stored integer.
Examples with std::uint8_t storage:
using Micro = ae::FixedPoint<std::uint8_t, 0.001>;
// kScaleExp = -17
// step = 2^-17 ~= 0.000007629
// raw 131 ~= 0.000999
// max representable ~= 0.001945
using Huge = ae::FixedPoint<std::uint8_t, 1000000.0>;
// kScaleExp = 12
// step = 4096
// raw 244 ~= 999424
// max representable = 1044480So the point may be many bits beyond an 8-bit value in either direction. For tiny ranges, the raw byte becomes a fine fractional value. For huge ranges, the raw byte becomes a coarse bucket index.
Signed storage uses a symmetric raw range around zero. For example, std::int8_t uses -127..+127, not the full INT8_MIN..INT8_MAX asymmetry.
Runtime arithmetic is integer-only: shifts, rounding, saturated raw add/sub, and scale conversion. Floating-point appears only at compile-time or parse/debug boundaries.
Exponential is an approximate logical codec for values that span orders of magnitude.
It stores only a compact integer code (WireT). The decoded runtime value lives in RuntimeT.
using Runtime = ae::FixedPoint<std::uint32_t, 1.0>;
using E = ae::Exponential<Runtime, std::uint8_t, 0.001, 1.0>;
constexpr auto encoded = E::FromDouble(0.1);
constexpr auto decoded = encoded.ToRuntime();API semantics:
Code()/FromCode()construct from an exact wire code without magnitude approximation.Value()/ToRuntime()decode a code to a runtime value.FromRuntime()/FromDouble()encode a runtime value to a code.Abs,Min,Max, andClampoperate on wire codes directly.
Code 0 is zero. For unsigned runtime types, code 1 is MinMagnitude, and BoundaryCode is BoundaryMagnitude.
For signed runtime types, even codes are positive magnitudes and odd codes are negative magnitudes: code 2 is +MinMagnitude, code 1 is -MinMagnitude.
When BoundaryCode is omitted, Exponential uses the maximum logical code representable by WireT (numeric_traits<WireT>::kMaxBoundaryCode).
For built-in wire types such as std::uint8_t, the default boundary code is 255.
For TieredInt<std::uint8_t, 249, 1529>, the default boundary code is 1529.
Pass the fifth template parameter only when you intentionally want to use a smaller code range than the full wire range.
Use FixedPoint or an integral runtime type for embedded builds. For desktop, server, tests, or reference code, include the optional floating runtime header before using float or double as RuntimeT:
#include <ae-numeric/exponential_floating_runtime.h>
using EFloat = ae::Exponential<float, std::uint8_t, 0.001f, 1.0f>;float and double are runtime value types only. They are not valid storage types for TieredInt or FixedPoint.
ae-numeric/text_io.h provides integer-only decimal conversion for TieredInt, FixedPoint, and Exponential.
FixedPoint formatting derives the logical value from raw * 2^kScaleExp using shifts and exact rational fraction expansion.
#include <ae-numeric/text_io.h>
using F = ae::FixedPoint<std::uint8_t, 100.0>;
auto s = ae::ToString(F::FromRaw(1)); // "0.5"
F parsed;
ae::FromString("10.5", parsed);Core APIs:
ae::ToString(value)— allocates astd::string;ae::FromString(text, value)— returnsfalseon malformed or out-of-range input;ae::ToChars(first, last, value)— non-allocating buffer write;ae::FromChars(first, last, value)— non-allocating parse.
ae-numeric/ostream_io.h provides optional operator<< for TieredInt and FixedPoint.
Include it only where stream output is actually needed; core numeric headers do not pull in <ostream>.
#include <ae-numeric/ostream_io.h>
using F = ae::FixedPoint<std::uint8_t, 100.0>;
std::cout << ae::TieredInt<std::uint8_t, 254>{123}; // 123
std::cout << F::FromRaw(1); // 0.5ae-numeric/wire_io.h provides a uniform serialization API for built-in integers, TieredInt, and FixedPoint through wire_traits<T> and convenience functions.
ae-numeric/exponential_wire_io.h adds wire traits for Exponential.
#include <ae-numeric/wire_io.h>
using F = ae::FixedPoint<ae::TieredInt<std::uint8_t, 254>, 60.0>;
std::uint8_t buf[ae::MaxWireBytes<F>()];
auto n = ae::Serialize(F::FromInteger(30), buf);
auto restored = ae::Deserialize<F>(buf, n).value;Serialization rules:
- built-in integers — fixed-width little-endian (
sizeof(T)bytes); TieredInt— compact variable-length encoding;FixedPoint— serializes only the rawRepstorage;Exponential— serializes only theWireTcode.
Short or invalid buffers are handled by the corresponding deserializer result or debug assertions; the numeric core does not use exceptions.
You can combine TieredInt, FixedPoint, and Exponential to get compact wire encoding and useful runtime semantics.
This maps durations from 1 ms to 60 s onto exponential codes stored in a packed integer:
#include <ae-numeric/exponential_wire_io.h>
using Runtime = ae::FixedPoint<std::uint32_t, 60.0>;
using Wire = ae::TieredInt<std::uint8_t, 249, 1529>;
using Duration = ae::Exponential<Runtime, Wire, 0.001, 60.0>;
constexpr auto one_second = Duration::FromDouble(1.0);
std::uint8_t buf[ae::MaxWireBytes<Duration>()];
auto n = ae::Serialize(one_second, buf);Here Wire stores codes 0..249 in one byte and codes 250..1529 in two bytes.
Because Duration stores only the code, small durations use one byte. With this particular logarithmic mapping, the one-byte region covers approximately 1 ms through 6 ms; the full range up to 60 s uses two bytes.
For latency and round-trip measurements, it is often useful to keep the common fast path in one byte while still allowing large outliers.
using Runtime = ae::FixedPoint<std::uint32_t, 43.0>;
using Wire = ae::TieredInt<std::uint8_t, 254>;
using Latency = ae::Exponential<Runtime, Wire, 0.001, 43.0, 510>;For TieredInt<std::uint8_t, 254>, wire codes 0..254 serialize to one byte and codes 255..510 serialize to two bytes.
With the exponential mapping from 1 ms to 43 s:
- code
1is 1 ms; - code
254is about 200.9 ms; - code
255is about 205.2 ms; - code
510is 43 s.
So typical 1 ms to roughly 200 ms latencies stay in one byte, while slower values up to 43 s still fit in two bytes.
For telemetry where linear precision is preferred over relative precision, use FixedPoint over TieredInt.
This example stores ping time in milliseconds with 1 ms resolution:
using PingRaw = ae::TieredInt<std::uint8_t, 250, 1500>;
using PingMs = ae::FixedPoint<PingRaw, 1000000.0>;
constexpr auto fast = PingMs::FromInteger(42);
constexpr auto mtu_like_timeout = PingMs::FromInteger(1500);For this type:
0..250 msserialize to one byte;251..1500 msserialize to two bytes;- rare large values can use the four-byte tier;
- the physical representable maximum is about 1,967,580 ms, while the declared safe range is 1,000,000 ms.
This is a linear scale: every raw step is one millisecond. Use Exponential instead when relative precision is more important than absolute millisecond spacing.
- Header-only numeric types.
- C++20.
- Deterministic integer runtime paths for embedded use.
- Optional floating runtime support for
Exponentialis isolated inae-numeric/exponential_floating_runtime.h. - Designed for low-overhead serialization on MCUs and constrained networks.
Build and run the unit tests with CMake:
git clone https://github.com/aethernetio/aethernet-numeric.git
cd aethernet-numeric
git submodule update --init --recursive
cmake -S . -B build-dev \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_STANDARD=20 \
-DAE_BUILD_TESTS=ON
cmake --build build-dev
ctest --test-dir build-dev --output-on-failureSelect a specific compiler through normal CMake command-line options when needed.
© Æthernet Inc. — Part of the Æthernet Core Client Library https://aethernet.io