Units and measurements

Base quantity

The base_quantity struct represents a physical quantity by storing the powers of the seven base quantities of the International System of Units (SI) - length, time, mass, temperature, electric_current, substance_amount, and luminous_intensity, as template parameters:

template <int LENGTH, int TIME, int MASS, int TEMPERATURE, 
          int ELETTRIC_CURRENT, int SUBSTANCE_AMOUNT, int LUMINOUS_INTENSITY>
struct base_quantity {

    static constexpr std::array<int, 7> powers = {LENGTH, TIME, MASS, TEMPERATURE, ELETTRIC_CURRENT, SUBSTANCE_AMOUNT, LUMINOUS_INTENSITY}; ///< array of the powers of the base quantities

    static constexpr std::array<std::string_view, 7> base_literals = {"m", "s", "kg", "K", "A", "mol", "cd"}; //< array of the literals of the base quantities

    /// @brief Returns the string representation of the base_quantity
    static constexpr std::string_view to_string() noexcept {

        std::stringstream ss;

        // Iterate over the base quantities and their powers
        meta::for_<7>([&](auto i) constexpr {

            if constexpr (powers[i] != 0) {

                if constexpr (powers[i] == 1) // Append the base quantity to the string builder
                    ss << base_literals[i]; 

                else // Append the base quantity and its power to the string builder
                    ss << base_literals[i] << '^' << powers[i]; 

            }
            
        });

        return ss.str();

    }

};

The powers of the base quantities can be accessed using the corresponding aliases. It is possible to define custom base_quantities using the constuctor providing the powers, or just by combining existing types using the basic operations, defined inside the scipp::math::op namespace, such as multiplication, division, and exponentiation. The base quantities are not meant to be used directly, but rather as template parameters for the unit and measurement structs.

The principal base quantities are defined in the scipp::physics::base namespace:

namespace base {

    using length = base_quantity<1, 0, 0, 0, 0, 0, 0>;                      ///< metre base_quantity
    using time = base_quantity<0, 1, 0, 0, 0, 0, 0>;                        ///< second base_quantity                   
    using mass = base_quantity<0, 0, 1, 0, 0, 0, 0>;                        ///< kilogram base_quantity
    using temperature = base_quantity<0, 0, 0, 1, 0, 0, 0>;                 ///< kelvin base_quantity
    using electric_current = base_quantity<0, 0, 0, 0, 1, 0, 0>;            ///< ampere base_quantity
    using substance_amount = base_quantity<0, 0, 0, 0, 0, 1, 0>;            ///< mole base_quantity
    using luminous_intensity = base_quantity<0, 0, 0, 0, 0, 0, 1>;          ///< candela base_quantity

    using angle = math::op::divide_t<length, length>;                        ///< radian base_quantity
    using area = math::op::multiply_t<length, length>;                       ///< square_metre base_quantity
    using volume = math::op::multiply_t<length, area>;                       ///< cubic_metre base_quantity
    using velocity = math::op::divide_t<length, time>;                       ///< metre_per_second base_quantity
    using acceleration = math::op::divide_t<velocity, time>;                 ///< metre_per_second_squared base_quantity
    using angular_velocity = math::op::divide_t<angle, time>;                ///< radian_per_second base_quantity
    using angular_acceleration = math::op::divide_t<angular_velocity, time>; ///< radian_per_second2 base_quantity
    using momentum = math::op::multiply_t<mass, velocity>;                   ///< kilogram_metre_per_second base_quantity
    using impulse = math::op::multiply_t<momentum, time>;                    ///< kilogram_metre_per_second base_quantity
    using angular_momentum = math::op::multiply_t<momentum, length>;         ///< kilogram_metre_squared_per_second base_quantity
    using force = math::op::multiply_t<mass, acceleration>;                  ///< newton base_quantity
    using torque = math::op::multiply_t<force, length>;                      ///< newton_metre base_quantity
    using energy = math::op::multiply_t<force, length>;                      ///< joule base_quantity
    using action = math::op::multiply_t<energy, time>;                       ///< joule_second base_quantity
    using power = math::op::divide_t<energy, time>;                          ///< watt base_quantity
    using pressure = math::op::divide_t<force, area>;                        ///< pascal base_quantity
    using density = math::op::divide_t<mass, volume>;                        ///< kilogram_per_cubic_metre base_quantity
    using frequency = math::op::invert_t<time>;                              ///< hertz base_quantity

    [...]

}

Unit of measure

The unit struct represents a unit of measurement by combining a base_quantity with an std::ratio prefix:

template <typename BASE_T, typename PREFIX_T = std::ratio<1>> 
    requires (is_base_v<BASE_T> && is_prefix_v<PREFIX_T>)  
struct unit {

    using base_t = BASE_T;      ///< base_quantity type
    using prefix_t = PREFIX_T;  ///< prefix type

    /// @brief Return a string representation of the unit
    static constexpr std::string to_string() noexcept {

        std::stringstream ss;
        auto factor = static_cast<double>(prefix_t::num) / static_cast<double>(prefix_t::den); 
        constexpr auto prefix = std::lower_bound(prefix_map.begin(), prefix_map.end(), factor,
            [](const auto& p, const auto& value) { return p.first < value; });
        
        if constexpr (prefix == prefix_map.end())
            ss << base_t::to_string();
        else
            ss << "[" << prefix->second << "]" << base_t::to_string();

        return ss.str();

    }

};

In the units namespace are defined the most common units of measure:

namespace units {

    using metre = unit<base::length, std::ratio<1>>;    ///< metre unit
    inline static constexpr metre m;                    ///< m unit

    using kilometre = unit<base::length, std::kilo>;    ///< kilometre unit
    inline static constexpr kilometre km;               ///< Km unit

    using decimetre = unit<base::length, std::deci>;    ///< decimetre unit
    inline static constexpr decimetre dm;               ///< dm unit

    using centimetre = unit<base::length, std::centi>;  ///< centimetre unit
    inline static constexpr centimetre cm;              ///< cm unit

    [...]

    using second = unit<base::time, std::ratio<1>>;     ///< second unit
    inline static constexpr second s;                   ///< s unit

    using minute = unit<base::time, std::ratio<60>>;    ///< minute unit
    inline static constexpr minute min;                 ///< min unit

    using hour = unit<base::time, std::ratio<3600>>;    ///< hour unit
    inline static constexpr hour h;                     ///< h unit
    
    [...]

    using kilogram = unit<base::mass>;                  ///< kilogram unit
    inline static constexpr kilogram kg;                ///< kg unit

    using gram = unit<base::mass, std::milli>;          ///< gram unit
    inline static constexpr gram g;                     ///< g unit

    [...]


    using litre = cubic_decimetre;                      ///< litre unit
    inline static constexpr litre L;                    ///< L unit

    using joule = unit<base::energy>;                   ///< Joule unit
    inline static constexpr joule J;                    ///< J unit

    using newton = unit<base::force>;                   ///< Newton unit
    inline static constexpr newton N;                   ///< N unit

    using pascal = unit<base::pressure>;                ///< Pascal unit
    inline static constexpr pascal Pa;                  ///< Pa unit

    [...]

}

Measurement

Most of the time we write code to solve a problem that has a physical meaning, we assume that the code that we write is physically correct, but often we do make mistakes or, simply, we do not fully understand the problem we are trying to solve yet.

A simple example

Consider this basic example, where we want to do something physically meaningful, like calculating the force upon an object. Suppose an apple fell from a tree and hit your head so hard that you can not remember Newton’s second law of motion. You decide to calculate the force using whatever physical quantity presents itself to you. You measure the length of the tree and the mass of the apple and use them to calculate the force in this way:

double calculate_force(double mass, double length) {

  return mass + length;

}

int main() {

  // This code will compile because it's not wrong to add any generic numbers
  double length{2.5};
  double mass{3.0};
  double force = calculate_force(length, mass);

  return 0;   
}

The code compiles, but it would give you a wrong anwser, because of the wrong physics law you choose to invent to calculate the force. This is due to the fact that the compiler does not know the physical meaning of the code, it only knows the type of the variables, which is not enough to understand the physical meaning of the code. To find these types of error, if we are lucky enough to notice some weird numerical results, we need to read the code and understand the physical meaning of the code, which is not always easy. As long as the compiler can not distinguish between numbers used to represents different physical quantities, it does not help us in any way and we are left with a mad search through the code. The solution is to use a type that encodes the physical meaning of the variable as well as its value type. The scipp library comes in handy here.

The measurement struct represents a physical measurement as a value with a dimensional-aware template meta-structure. Using the measurement struct, it is possible to perform dimensional analysis at compile-time without loss of precision and overhead. Basically, writing physical accurate code is now possible and easy!

template <typename BASE_T, typename VALUE_TYPE = double>
    requires (is_base_v<BASE_TYPE> && math::is_number_v<VALUE_TYPE>)
struct measurement {

    using base_t = BASE_TYPE;   ///< The base of the measurement
    using value_t = VALUE_TYPE; ///< The type of the value of the measurement

    value_t value;              ///< The value of the measurement


    static constexpr measurement zero{value_t{}};   ///< The zero measurement

    static constexpr measurement one{value_t{1.0}}; ///< The one measurement


    /// @brief Default constructor
    constexpr measurement() noexcept : value{value_t{}} {}

    /// @brief Construct a measurement from a scalar value
    constexpr measurement(const value_t& val) noexcept : value{val} {}

    /// @brief Construct a measurement from a scalar value
    constexpr measurement(value_t&& val) noexcept : value{std::move(val)} {}

    /// @brief Construct a measurement from a scalar value and an unit
    /// @note The unit must be of the same base of the measurement 
    /// @note The value is immediatly converted to the base unit
    template <typename UNIT_TYPE> 
        requires (is_unit_v<UNIT_TYPE> && is_same_base_v<base_t, typename UNIT_TYPE::base_t>)
    constexpr measurement(value_t&& val, const UNIT_TYPE&) noexcept {

        using prefix_t = typename UNIT_TYPE::prefix_t;
        constexpr auto factor = static_cast<double>(prefix_t::num) / static_cast<double>(prefix_t::den);
        this->value = std::forward<value_t>(val * factor);
        
    }

    /// @brief Implicitly convert the measurement to a scalar value if it is a scalar base measurement
    constexpr operator value_t() const requires is_scalar_base_v<base_t> { 
        
        return this->value; 

    }

    /// @brief Extract the value of this measurement converted to a specific unit
    /// @note The unit must be of the same base of the measurement
    template <typename T>
    constexpr auto value_as(const unit<base_t, T>&) const noexcept {

        return this->value / static_cast<double>(T::num) * static_cast<double>(T::den);

    }   


    /// @brief Print a measurement to an output stream
    friend constexpr std::ostream& operator<<(std::ostream& os, const measurement& other) noexcept { 
        
        os << other.value; 
        if constexpr (!is_scalar_base_v<base_t>) 
            os << ' ' << base_t::to_string();
        return os;
        
    }

};

The value type is defaulted to double, but it can be changed to any type that supports the basic arithmetic operations. Operations between measurements with different units are allowed and they respect the dimensional analysis rules, i.e. we cannot sum two measurements with different base units, but we can multiply them to product something with a different physical meaning. For example, we can write the previous code in this way:

int main() {
  measurement<base::length, double> length{1.0};
  measurement<base::mass, double> mass{60.0};
  measurement<base::force, double> force{length * mass};
}

This code will not compile yet because it contains incorrect physical operations, but at least the physical error in the code is now clear to the compiler, which will give us an error message that will help us understand the problem:

error: conversion from measurement<base_quantity<[...],0,[...],[...],[...],[...],[...]>,[...]> to non-scalar type measurement<base_quantity<[...],-2,[...],[...],[...],[...],[...]>,[...]> requested here:
    measurement<base::force, double> force = length * mass;

We are indeed trying to assign a value obtained from the multiplication of a length and a mass, which does not correspond to the physical meaning of the force, to a variable of type measurement<base::force, double>.

Remembering Newton second law, we can rewrite the previous code in the correct way:

int main() {
  measurement<base::acceleration, double> a{1.0};
  measurement<base::mass, double> m{60.0};
  measurement<base::force, double> F = m * a;
}

Maybe you are thinking that this is all very nice, but that it is not worth the effort to write code in this way, because it is too complicated, but simply this is not true. In fact, the code is not more complicated, it is just different. Please note that this is all done at compile time, so we don’t have to run the code and maybe, if we are lucky, find a little suspicius numerical result that will make us think that something is wrong in the code and then manually debug it to find the error. The compiler will tell us exactly where the error is and what the error is, so we can fix it immediately and return to code. Another advantage of using the measurement struct is that we can do not bother about conversions between units, instead we can just write our physical code in a natural way, as we would do on paper.

Usage

Creating measurements

In the following example we show how to create measurements and how to use them.

    // Creating measurements with explicit value_type and base_quantity
    measurement<base::length, int> l0(1, units::m);     

    print(sizeof(l0));                                  ///< '4' (bytes)

    // Creating measurements by multiplying a number by a unit 
    measurement<base::length> l1 = 2.0 * units::m;      
    // @note the value_type is deduced from the number
    // @note the unit base_quantity must be compatible with the base_quantity of the measurement

    print(sizeof(l1));                                  ///< '8' (bytes)

    // Creating measurements with implicit deduction of base quantity and value type
    measurement l2 = 3.0 * units::m;    
    print(l2);                                          ///< '3.0 m'

    // Creating measurements with a prefixed unit
    auto l3 = 4.0 * units::cm;                          
    
    print(l3);                                          ///< '0.04 m'
    // @note The prefix is automatically applied to the value by multiplying it by the prefix factor

    // Printing a measurement with a specific unit type
    print<units::centimetre>(l3);                       ///< '4.0 [c]m'
    print(l3, units::cm);                               ///< '4.0 [c]m'
    // @note the unit base_quantity must be compatible with the base_quantity of the measurement


    // Using literal operators for units
    using namespace units::literals;
    auto l4 = 5.0m;                                     
    auto l5 = 6.0mm;                                    

    print(l4);                                          ///< '5.0 m'
    print(l5);                                          ///< '0.006 m'


    // Creating measurements by combining other measurements
    auto l6 = l1 + l2;                                  
    
    print(l6);                                          ///< '5.0 m'
    print(l1 * l2);                                     ///< '6 m^2'
    print("l1 / l2 = ", l1 / l2);                       ///< 'l1 / l2 = 0.666667'
    print<std::centi>("l1 / l2 = ", l1 / l2);           ///< 'l1 / l2 = 0.67'
    print<std::milli>("l1 / l2 = ", l1 / l2);           ///< 'l1 / l2 = 0.667'

A more concrete example

Consider an example, a little more complex, in which we want to plot the intensity of the diffraction pattern generated by a light beam, passing through a slit on a screen at a fixed distance, given by the integral function:

\[I (x) = \int _{-\frac{d}{2}} ^{\frac{d}{2}} dx' cos(\frac{2\pi}{\lambda} \cdot (\sqrt{L^2 + (x - x')^2} - \sqrt{L^2 + x^2}))\]

where $d$ is the slit width, $\lambda$ is the wavelength of the light and $x$ is the distance from the center of the screen.

We can write the code in this way:

#include "scipp"

using namespace scipp;

using namespace physics;            // for measurements 
using namespace units;              // for the units
using namespace units::literals;    // for the units literals

using namespace math; 
using namespace op;                 // for the operators and the functions
using namespace calculus;           // for the integral function and the interval struct

using tools::print;                 // for the print function


int main() {

    /// parameters of the experiment
    constexpr auto d = 10.0um;                          /// slit width
    constexpr auto lambda = 589.0nm;                    /// wavelength
    constexpr auto L = 0.5m;                            /// distance from the slit to the screen

    constexpr auto I_int = interval(-0.5 * d, 0.5 * d); /// interval of integration

    measurement<base::length> x = -0.2m;                /// variable for the function I(x)
    constexpr auto I = interval(-0.2m, 0.2m);           /// interval of points where we want to evaluate I(x)
    constexpr auto dx = 1.0mm;                          /// distance between two points
    constexpr size_t N = I.steps(dx);                   /// numer of points with the math::calculus::interval function


    /// we define the integral function of I(x) as an std::function 
    /// we use a lambda function and we pass by value the parameters and by reference the integral variable
    /// the function is a scalar function, so it returns a measurement with base::scalar
    std::function f = [lambda, L, &x](measurement<base::length> t) -> measurement<base::scalar> {   

        constexpr auto k = 2.0 * std::numbers::pi / lambda;     /// wave number
        return cos(k * (hypot(L, x - t) - hypot(L, x)));        /// using the math::op::hypot function     

    }; 


    print<micrometre>("d = ", d);                       /// we can specify the unit of measurement as a template parameter (type)
    print("lambda = ", lambda, nm);                     /// or we can specify the unit of measurement as a function parameter (value)
    print("interval of integration: ", I_int);          /// we can print the interval too
    print("f(0.0m) = ", f(0.0m));                       /// we can evaluate the function at a point


    std::vector<double> integral_values(N), x_values(N); /// to store the values for the plot

    /// we iterate over the interval using the meta::for_ function
    meta::for_<N>([&](auto i) { 

        /// we store the value of x, not the measurement
        x_values[i] = x.value; 

        /// we evaluate the integral using i.e. the midpoint rule from the math::calculus namespace
        /// the result of the integral is the width of the diffraction pattern
        /// the integration is correct from a dimensional point of view: the result is a measurement with base::length
        /// the precision of the calculation could be specified using an std::ratio as a template parameter
        /// you can also just fix a number of steps as a function parameter or template parameter, as you prefer
        integral_values[i] = midpoint<std::micro>(f, I_int).value_as(um); /// we extract the value of the integral in micrometres

        /// we increment the x variable by dx
        x += dx; 

    });

    /// we plot the results using the matplotlib-cpp library
    plt::figure();
    plt::title("Diffracted intensity");
    plt::plot(x_values, integral_values);
    plt::xlabel("x [m]");  // we can specify the unit of measurement
    plt::ylabel("I [um]"); // we can specify the unit of measurement
    plt::grid(true);
    plt::tight_layout();
    plt::save("images/diffracted_intensity.png");
    plt::show();

    return 0; 

}

Here is the output of the program:

d = 10 [u]m
lambda = 589 m
interval of integration: [ -5e-06 m, 5e-06 m ]
f(0.0m) = 1

plot

Benchmarks

The benchmarks are performed using the Google Benchmark library and can be found in the benchmark folder from the root of the repository. Here are the results of the benchmarks of the scipp::math::op functions on double and measurement, in comparison with the standard operators built-in in the C++ language: ``` bash Run on (8 X 3400 MHz CPU s) CPU Caches: L1 Data 32 KiB (x4) L1 Instruction 32 KiB (x4) L2 Unified 256 KiB (x4) L3 Unified 6144 KiB (x1) Load Average: 0.74, 0.97, 1.12

Running ./build/benchmark/op/double

Benchmark Time CPU Iterations —————————————————— BM_Add 0.628 ns 0.628 ns 1000000000 BM_Multiply 0.628 ns 0.628 ns 1000000000 BM_Invert 0.628 ns 0.628 ns 1000000000 BM_Square 0.628 ns 0.628 ns 1000000000

Running ./build/benchmark/op/measurement

Benchmark Time CPU Iterations —————————————————— BM_Add 0.629 ns 0.629 ns 1000000000 BM_Multiply 0.629 ns 0.629 ns 1000000000 BM_Invert 0.628 ns 0.628 ns 1000000000 BM_Square 0.628 ns 0.628 ns 1000000000

Running ./build/benchmark/op/built_in

Benchmark Time CPU Iterations —————————————————— BM_Add 0.629 ns 0.629 ns 1000000000 BM_Multiply 0.629 ns 0.629 ns 1000000000 BM_Invert 0.629 ns 0.629 ns 1000000000 BM_Square 0.635 ns 0.635 ns 1000000000