This guide explains how to add new unit types to qtty-cpp, from modifying the Rust qtty-ffi library through generating C++ wrappers and adding tests.
Table of Contents
- Overview
- Prerequisites
- Step-by-Step Guide
- Example: Adding a New Length Unit
- Example: Adding a New Dimension
- Testing New Units
- Troubleshooting
Overview
Adding a new unit to qtty-cpp is a multi-step process that spans both the Rust qtty-ffi library and the C++ wrapper layer:
1. Modify Rust qtty-ffi
- Define new unit in Rust code
2. Rebuild qtty-ffi
- Generate updated qtty_ffi.h with new unit IDs
3. Regenerate C++ wrappers
- Run the Rust `gen_cpp_units` tool to create C++ bindings
4. Add tests
- Write Google Test cases for the new unit
5. Update documentation
- Document the new unit in README and API docs
Important: This guide assumes you're adding units to an existing dimension (Length, Time, Angular, Mass, Power). Adding a completely new dimension requires more extensive changes.
Prerequisites
Before adding new units, ensure you have:
Step-by-Step Guide
Step 1: Add Unit to Rust qtty-ffi
The qtty-ffi library is located in the qtty/ submodule. You'll need to modify the appropriate Rust source files.
Location: qtty/qtty-ffi/src/
1.1: Define the Unit Constant
Find the relevant dimension module in the qtty library (not qtty-ffi directly, as qtty-ffi depends on qtty).
For example, to add a new length unit:
Location: qtty/qtty/src/dimensions/length.rs (or similar)
Add your unit constant with appropriate conversion factor:
// Example: Adding "Chain" length unit
pub const CHAIN: Length = Length::new(20.1168); // meters
The conversion factor should be the value of 1 unit expressed in the base SI unit for that dimension:
- Length: meters
- Time: seconds
- Angle: radians
- Mass: kilograms
- Power: watts
1.2: Update FFI Layer
If the unit needs to be exposed through the FFI layer, add it to the unit enumeration:
Location: qtty/qtty-ffi/src/unit_id.rs (or wherever unit IDs are defined)
// Add to the appropriate enum
pub enum UnitId {
// Existing units...
#[doc = "Chain (ch)"]
Chain = 10042, // Choose next available discriminant in range
// More units...
}
Discriminant Ranges (see Architecture docs):
- Length: 10000-19999
- Time: 20000-29999
- Angle: 30000-39999
- Mass: 40000-49999
- Power: 50000-59999
Choose the next available number in the appropriate range.
1.3: Wire Up Conversion Logic
Ensure the conversion logic maps the unit ID to the appropriate constant:
Location: qtty/qtty-ffi/src/conversions.rs (or similar)
impl UnitId {
pub fn to_length(&self) -> Option<Length> {
match self {
UnitId::Meter => Some(Length::METER),
UnitId::Kilometer => Some(Length::KILOMETER),
// ... existing units ...
UnitId::Chain => Some(Length::CHAIN), // Add your unit
_ => None,
}
}
}
Step 2: Rebuild qtty-ffi
After modifying the Rust code, rebuild the FFI library to generate an updated qtty_ffi.h header:
cd qtty
cargo build -p qtty-ffi --release
cd ..
This regenerates qtty/qtty-ffi/include/qtty_ffi.h with your new unit ID constant.
Verify: Check that your new unit appears in the header:
grep "UNIT_ID_CHAIN" qtty/qtty-ffi/include/qtty_ffi.h
Expected output:
The format is critical for the C++ generator: /* DisplayName (symbol) */ UNIT_ID_NAME = discriminant
Step 3: Regenerate C++ Wrappers
Run the Python generator to create C++ bindings for the new unit:
cargo run --manifest-path gen_cpp_units/Cargo.toml --release -- \
qtty/qtty-ffi/units.csv include/qtty
This updates:
Verify: Check that your unit was generated:
# Check for tag struct
grep "ChainTag" include/qtty/units/length.hpp
# Check for type alias
grep "using Chain" include/qtty/units/length.hpp
# Check for literal (if symbol is valid for C++ literal suffix)
grep "_ch" include/qtty/literals.hpp
Expected additions:
Tag Struct:
UnitTraits Specialization:
template<> struct UnitTraits<ChainTag> {
static constexpr UnitId unit_id() { return UNIT_ID_CHAIN; }
};
Type Alias:
using Chain = Quantity<ChainTag>;
User-Defined Literal (if symbol is usable):
constexpr Chain operator""_ch(long double value) {
return Chain(static_cast<double>(value));
}
constexpr Chain operator""_ch(unsigned long long value) {
return Chain(static_cast<double>(value));
}
Step 4: Rebuild C++ Project
Rebuild the C++ project to ensure everything compiles:
cd build
cmake ..
make -j$(nproc)
If you encounter errors, see Troubleshooting below.
Step 5: Add Tests
Add comprehensive tests for your new unit in the appropriate test file.
Location: tests/test_length.cpp (or appropriate dimension test file)
Add test cases covering:
- Construction
- Literal usage (if available)
- Conversions to/from other units
- Arithmetic operations
- Comparisons
Example (adding Chain unit tests):
TEST_F(LengthDimensionTest, ChainConstruction) {
Chain c(10.0);
EXPECT_EQ(c.value(), 10.0);
auto c2 = 5.0_ch;
EXPECT_EQ(c2.value(), 5.0);
}
TEST_F(LengthDimensionTest, ChainConversions) {
Chain ch(1.0);
Meter m = ch.
to<Meter>();
EXPECT_NEAR(m.value(), 20.1168, 1e-9);
Meter m2(20.1168);
Chain ch2 = m2.
to<Chain>();
EXPECT_NEAR(ch2.value(), 1.0, 1e-9);
Kilometer km = ch.
to<Kilometer>();
EXPECT_NEAR(km.value(), 0.0201168, 1e-9);
}
TEST_F(LengthDimensionTest, ChainArithmetic) {
EXPECT_EQ(sum.value(), 15.0);
EXPECT_EQ(diff.value(), 5.0);
EXPECT_EQ(doubled.value(), 20.0);
EXPECT_EQ(half.value(), 5.0);
}
TEST_F(LengthDimensionTest, ChainComparison) {
Chain a(10.0), b(5.0), c(10.0);
EXPECT_EQ(a, c);
EXPECT_NE(a, b);
EXPECT_GT(a, b);
EXPECT_LT(b, a);
}
Quantity< typename ExtractTag< TargetType >::type > to() const
Quantity< ChainTag > Chain
Step 6: Run Tests
Rebuild and run tests to verify your new unit works correctly:
cd build
make test_ffi
./test_ffi --gtest_filter=*Chain*
All tests should pass.
Step 7: Update Documentation
Update documentation to include the new unit:
Add the unit to the "Supported Units" section:
### Length
- `Meter` (m)
- `Kilometer` (km)
- `Chain` (ch) # Add this
- ...
Literals Table
If the unit has a user-defined literal, add it to the literals table in README.md and docs/api/quantities.md:
| `_ch` | Chain | `20.0_ch` |
Example: Adding a New Length Unit
Let's walk through a complete example: adding the "Furlong" unit (1 furlong = 201.168 meters).
1. Add to Rust qtty Library
File: qtty/qtty/src/dimensions/length.rs
pub const FURLONG: Length = Length::new(201.168);
2. Add FFI Unit ID
File: qtty/qtty-ffi/src/unit_id.rs
#[doc = "Furlong (fur)"]
Furlong = 10043,
3. Wire Conversion
File: qtty/qtty-ffi/src/conversions.rs
UnitId::Furlong => Some(Length::FURLONG),
4. Rebuild FFI
cd qtty
cargo build -p qtty-ffi --release
cd ..
5. Verify Header
grep Furlong qtty/qtty-ffi/include/qtty_ffi.h
Output:
6. Generate C++ Wrappers
cargo run --manifest-path gen_cpp_units/Cargo.toml --release -- \
qtty/qtty-ffi/units.csv include/qtty
Output:
Reading units from: qtty/qtty-ffi/units.csv
Generated length.hpp with 58 units # Count increased
Generated literals.hpp
...
7. Verify Generated Code
grep Furlong include/qtty/units/length.hpp
Output:
struct FurlongTag {};
template<> struct UnitTraits<FurlongTag> {
static constexpr UnitId unit_id() { return UNIT_ID_FURLONG; }
};
using Furlong = Quantity<FurlongTag>;
8. Check Literal
grep "_fur" include/qtty/literals.hpp
Output:
constexpr Furlong operator""_fur(long double value) {
return Furlong(static_cast<double>(value));
}
constexpr Furlong operator""_fur(unsigned long long value) {
return Furlong(static_cast<double>(value));
}
9. Add Tests
File: tests/test_length.cpp
TEST_F(LengthDimensionTest, FurlongConversions) {
Furlong fur(1.0);
Meter m = fur.
to<Meter>();
EXPECT_NEAR(m.value(), 201.168, 1e-9);
auto fur2 = 8.0_fur;
Mile mi = fur2.
to<Mile>();
EXPECT_NEAR(mi.value(), 1.0, 1e-6);
}
10. Build and Test
cd build
make test_ffi
./test_ffi --gtest_filter=*Furlong*
Output:
[ RUN ] LengthDimensionTest.FurlongConversions
[ OK ] LengthDimensionTest.FurlongConversions (0 ms)
[==========] 1 test from 1 test suite ran.
[ PASSED ] 1 test.
Done! The Furlong unit is now fully integrated.
Example: Adding a New Dimension
Adding a completely new dimension (e.g., Electric Current, Temperature) requires more extensive changes:
1. Add Dimension to Rust qtty
File: qtty/qtty/src/dimensions/mod.rs
pub mod current; // New dimension module
File: qtty/qtty/src/dimensions/current.rs
use crate::quantity::Quantity;
pub type Current = Quantity<CurrentDim>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CurrentDim;
impl Current {
pub const AMPERE: Self = Self::new(1.0);
pub const MILLIAMPERE: Self = Self::new(0.001);
// ... more units
}
2. Add FFI Enum Range
File: qtty/qtty-ffi/src/unit_id.rs
// Electric Current: 60000-69999
#[doc = "Ampere (A)"]
Ampere = 60000,
#[doc = "Milliampere (mA)"]
Milliampere = 60001,
3. Update Generator
File: gen_cpp_units/src/main.rs
Update the dimension mapping:
dimension_map = {
1: 'Length',
2: 'Time',
3: 'Angle',
4: 'Mass',
5: 'Power',
6: 'Current',
}
Add to dimension files mapping:
dimension_files = {
'Length': 'length.hpp',
'Time': 'time.hpp',
'Angle': 'angular.hpp',
'Mass': 'mass.hpp',
'Power': 'power.hpp',
'Current': 'current.hpp',
}
File: include/qtty/qtty.hpp
5. Update Literals Header Template
File: gen_cpp_units/src/main.rs
LITERALS_HEADER = """#pragma once
#include "units/length.hpp"
#include "units/time.hpp"
#include "units/angular.hpp"
#include "units/mass.hpp"
#include "units/power.hpp"
#include "units/current.hpp" // Add include
namespace qtty {
// ...
6. Add Test Suite
File: tests/test_current.cpp
#include "fixtures.hpp"
TEST_F(CurrentDimensionTest, AmpereConstruction) {
Ampere a(10.0);
EXPECT_EQ(a.value(), 10.0);
}
TEST_F(CurrentDimensionTest, CurrentConversions) {
Ampere a(1.0);
Milliampere ma = a.
to<Milliampere>();
EXPECT_NEAR(ma.value(), 1000.0, 1e-9);
}
7. Update CMakeLists.txt
File: CMakeLists.txt
Add to generated headers list:
set(GENERATED_HEADERS
# ... existing headers ...
${CMAKE_CURRENT_SOURCE_DIR}/include/qtty/units/current.hpp
)
Add test file to test sources:
set(TEST_FFI_SOURCES
# ... existing sources ...
tests/test_current.cpp
)
8. Update Fixtures
File: tests/fixtures.hpp
class CurrentDimensionTest : public QttyTest {};
Testing New Units
Recommended Test Coverage
For each new unit, add tests covering:
- Construction
- Constructor with value
- User-defined literal (if available)
- Default constructor (value should be 0.0)
- Conversions
- To base unit (e.g., Chain -> Meter)
- From base unit (e.g., Meter -> Chain)
- To other units in same dimension
- Round-trip conversion (A -> B -> A)
- Arithmetic
- Addition with same unit
- Subtraction with same unit
- Scalar multiplication
- Scalar division
- Compound assignment (+=, -=, *=, /=)
- Comparisons
- Equality (==, !=)
- Ordering (<, >, <=, >=)
- Utilities
- Edge Cases
- Very small values
- Very large values
- Zero
- Negative values
Test Template
Use this template for new unit tests:
TEST_F(DimensionNameTest, UnitNameConstruction) {
UnitName u(10.0);
EXPECT_EQ(u.value(), 10.0);
auto u2 = 5.0_suffix;
EXPECT_EQ(u2.value(), 5.0);
UnitName u3;
EXPECT_EQ(u3.value(), 0.0);
}
TEST_F(DimensionNameTest, UnitNameConversions) {
UnitName u(1.0);
BaseUnit base = u.to<BaseUnit>();
EXPECT_NEAR(base.value(), CONVERSION_FACTOR, TOLERANCE);
BaseUnit base2(CONVERSION_FACTOR);
UnitName u2 = base2.to<UnitName>();
EXPECT_NEAR(u2.value(), 1.0, TOLERANCE);
UnitName u3(42.0);
BaseUnit base3 = u3.to<BaseUnit>();
UnitName u4 = base3.to<UnitName>();
EXPECT_NEAR(u4.value(), 42.0, TOLERANCE);
}
TEST_F(DimensionNameTest, UnitNameArithmetic) {
UnitName a(10.0), b(3.0);
EXPECT_EQ((a + b).value(), 13.0);
EXPECT_EQ((a - b).value(), 7.0);
EXPECT_EQ((a * 2.0).value(), 20.0);
EXPECT_EQ((a / 2.0).value(), 5.0);
}
TEST_F(DimensionNameTest, UnitNameComparison) {
UnitName a(10.0), b(5.0), c(10.0);
EXPECT_EQ(a, c);
EXPECT_NE(a, b);
EXPECT_GT(a, b);
EXPECT_LT(b, a);
EXPECT_GE(a, c);
EXPECT_LE(b, a);
}
TEST_F(DimensionNameTest, UnitNameUtilities) {
UnitName neg(-10.5);
EXPECT_EQ(neg.abs().value(), 10.5);
EXPECT_EQ((-neg).value(), 10.5);
}
Troubleshooting
Issue: Unit Not Generated
Symptom: After running gen_cpp_units, your unit doesn't appear in the generated headers.
Diagnosis:
Check the generator output for warnings:
cargo run --manifest-path gen_cpp_units/Cargo.toml --release -- \
qtty/qtty-ffi/units.csv include/qtty
Common causes:
Comment format incorrect in qtty_ffi.h
Must be exactly: /* DisplayName (symbol) */ UNIT_ID_NAME = discriminant,
Check:
grep "UNIT_ID_YOURUNIT" qtty/qtty-ffi/include/qtty_ffi.h
Discriminant outside recognized range
Discriminants must be in defined ranges (10000-59999).
FFI header not regenerated
Rebuild qtty-ffi:
cd qtty
cargo build -p qtty-ffi --release
cd ..
Issue: Literal Suffix Collision
Symptom: Generator warning about skipping literal.
Example:
Warning: Skipping literal _nm for NauticalMile (conflicts with Nanometer)
Solution:
The generator automatically skips conflicting literals. Users can still use the constructor:
To add a unique literal manually:
File: include/qtty/literals.hpp
constexpr NauticalMile operator""_nmi(long double value) {
return NauticalMile(static_cast<double>(value));
}
constexpr NauticalMile operator""_nmi(unsigned long long value) {
return NauticalMile(static_cast<double>(value));
}
Issue: Conversion Factor Incorrect
Symptom: Tests fail with unexpected conversion values.
Example:
Expected: 1.0
Actual: 1.09361
Diagnosis:
The conversion factor in the Rust code is incorrect.
Solution:
- Verify the conversion factor (1 of your unit = ? base units)
- Update the Rust constant
- Rebuild qtty-ffi
- Regenerate C++ headers
- Rerun tests
Issue: Compile Errors After Adding Unit
Symptom: Build fails with template errors.
Common Errors:
Missing include in qtty.hpp
Add your dimension header:
#include "units/your_dimension.hpp"
Missing UnitTraits specialization
Should be auto-generated. Verify:
grep "UnitTraits<YourUnitTag>" include/qtty/units/*.hpp
Mismatched tag names
Tag struct name must match usage:
struct MeterTag {};
using Meter = Quantity<MeterTag>;
Issue: Tests Not Running
Symptom: ctest or ./test_ffi doesn't run your new tests.
Solution:
Ensure test file is added to CMakeLists.txt:
set(TEST_FFI_SOURCES
tests/main.cpp
tests/fixtures.hpp
tests/test_your_dimension.cpp # Add this
# ...
)
Rebuild test executable:
Verify tests are discovered:
./test_ffi --gtest_list_tests | grep YourTest
See Also