Dimensional analysis in Rust: how not to crash "Mars Climate Orbiter"



Software is complicated: ensuring that old features keep working, while holding the bugs away, is an every day’s fight. Thankfully, there are many (too often unknown) gems, either built in the language/compiler or as third-party libraries.

Some time ago, I introduced SymPy, which allows one to use symbolic maths in Python, and do things such as solving equations or integrating functions. In this article, I will introduce you another nice library, for Rust this time: uom (units of measurement). This crate helps performing dimensional analysis. In short, it ensures that adding meters with feet will give the correct result (by converting everything in the same base unit), while adding meters with seconds will not be allowed.

Mars Climate Orbiter entering the atmosphere of Mars at 57 km instead of skimming over at 226 km.
NASA did not use uom, it did not end well… - Wikipedia, CC BY

Uom

uom performs dimensional analysis, automatically, with type-safety and zero cost. In layman’s terms, it means that instead of working with measurement units (meter, kilometer, foot, mile, etc), uom works with quantities (in this example: length). It uses the compiler’s type system (at compile-time, without run-time overhead), to ensure that quantities and units are not be mixed up. If you try to add meters, kilometers and feet, uom will convert everything in the base unit (meters). If you try to add meters with seconds, the compiler will error out because the types are not compatible.

As uom’s documentation puts it:

No more crashing your climate orbiter!

Source: uom’s README

Here is a short example, to demonstrate how it is used:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
extern crate uom;
use uom::si::f32::*;
use uom::si::length::kilometer;
use uom::si::time::second;

fn main() {
    // some basic variables
    let length = Length::new::<kilometer>(5.0);
    let time = Time::new::<second>(15.0);

    // dividing a length by a time gives a velocity
    let velocity/*: Velocity*/ = length / time;

    // dividing a velocity by a time gives an acceleration
    let _acceleration = calc_acceleration(velocity, time);

    // adding a length and a time... does not make sense
    //let error = length + time; // error[E0308]: mismatched types
}

fn calc_acceleration(velocity: Velocity, time: Time) -> Acceleration {
    velocity / time
}

Why and how I use it

In the code of my Moon lander guidance software, I manipulate a lot of different types of data: position, velocity and acceleration, in both translation and rotation, but also times, masses, forces, and many others. They all have their own unit, and sometimes even several units (degrees and radians, depending on what I am doing). Some calculations can mix many different units: for instance, when computing aerodynamic drag, you start with a density and a velocity to get a pressure, which you then use with a length to obtain a a force, which you finally express as an acceleration using a mass. All of this is very error prone and a disaster is bound to happen (spoiler: it did, and was discovered thanks to uom).

Using uom ensures that there are no such easily detectable and fixable errors as mixed units. Letting the compiler work allows me to focus on better value-adding tasks: I can spend less time checking that the code is correct which allows me to add features faster. It increases my confidence in the code, which allows me to use my full brain power to focus on actual features and not second guessing everything I do.

Here are a few examples. First a simple one (acc/vel/pos) and second is a bit more complex (aerodynamic drag).

1
2
3
spacecraft.acc = sensors.acc_read();
spacecraft.vel += mul!(spacecraft.acc, dt);  // Better written as "spacecraft.acc*dt",
spacecraft.pos += mul!(spacecraft.vel, dt);  // see next section.
1
2
3
4
5
6
7
let vel: Velocity = Velocity::new::<meter_per_second>(norm!(spacecraft.vel));
// dynamic pressure q: Pa = Kg / (m*s**2)
let dp_q: Pressure = 0.5 * conf.body.atmosphere_density(spacecraft.pos.y) * squared!(vel);
// dynamic pressure n: N = Kg / (m*s**2) * m**2 = Kg*m / s**2
let dp_n: Force = dp_q * (PI*squared!(conf.sc_width/2.0)) * conf.sc_cd;
// acceleration: m/s**2 = N / kg
spacecraft.acc_atm = -dp_n/sc_mass;

Pros & cons

Pros

I already had a lot of code, and I had to convert it to use uom. The library is rather easy and transparent to use, so it took me only a few hours to convert everything. It was worth it, as I found a bug: I was trying to compute the angular acceleration, from the pitch, but I went to fast and derived only once instead of two times. Uom caught the error with a clear error message.

The diff looks as follows (full code). It is part of a function which reads KSP’s sensors and convert the pitch into the angular acceleration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
impl Adapter for AdapterKSP<'_> {
    fn read_sensors(&mut self) -> SensorsValues {

        /* ... some code ... */

        let pitch = Angle::new::<degree>(/* ... */);

        // Can not compile: Angle/Time is AngularVelocity, not AngularAcceleration
        let mut ang_acc: AngularAcceleration = (pitch-self.last_pitch)/dt;

        // This is ok
        let mut ang_vel: AngularVelocity = ((pitch-self.last_pitch)/dt).into();
        let mut ang_acc: AngularAcceleration = ((ang_vel-self.last_ang_vel)/dt).into();

        /* ... more code ... */

    }
}

Cons

Regarding cons, uom can sometimes be missing some seldom used quantities and units. For example, it does not have the moment of inertia unit. Thankfully, that’s an easy fix, and the maintainer welcomes Pull Requests.

Another minor con, that you might have noticed in the code examples, is that uom is a bit very verbose. Measurements and quantities could have alternative short names: for example mps instead of meter_per_second.

The biggest downside is when wrapping uom quantities in structs: I wanted to create a vector with two fields, to represent a vertical and horizontal position/velocity/acceleration. It proved to be impossible to implements methods on this struct, such as adding a delta position to a position (vecpos += vecvel * dt). As a workaround, I use macros, but it does not seems very clean, and makes the compiler’s error messages more complicated.

Conclusion

As with everything in life, uom is not perfect (usage is a bit verbose, vectors are clunky, etc), but is nonetheless very useful (I found and fixed a bug after all): It is a way to improve the state of software and avoid some errors that can be made without this library.

Although a project using this library is safe on its own, there is one thing to watch out for: interfaces. That’s always where issues are, and in this case uom will not help: for example, if a Rust module using uom is interfaced with an Ada or C (embedded) or Python (ground station) module, uom is of no help in ensuring that the interfaces match. Software developers will not be jobless anytime soon…