Skip to content
Advanced

Rounding

to converts only when the result is exact. Where losing part of a step is the intent (a reading shown in coarser steps, Q15 narrowed to Q7), the conversion names how it rounds.

use exact_units::{Fixed, Rounding, prefix::{Deci, Milli}, units::Volt};

let v: Fixed<Volt, Milli, i16> = Fixed::new(1250);
assert_eq!(v.to::<Deci, i16>(), None); // 12.5 dV isn't whole
assert_eq!(v.to_rounded::<Deci, i16>(Rounding::NearestEven).map(|x| x.get()), Some(12));
assert_eq!(v.to_rounded::<Deci, i16>(Rounding::NearestAway).map(|x| x.get()), Some(13));

The five modes

Rounding Rule 2.5 −2.5 2.4 Where you meet it
Truncate \(\operatorname{trunc}(x)\), toward 0 2 −2 2 integer division in Rust and C
Floor \(\lfloor x \rfloor\), toward \(-\infty\) 2 −3 2 an arithmetic shift right
Ceil \(\lceil x \rceil\), toward \(+\infty\) 3 −2 3 buffer sizes
NearestEven nearest, a tie to the even one 2 −2 2 IEEE 754's default, Python's round
NearestAway nearest, a tie away from 0 3 −3 2 C's round and lround

The modes differ only where \(x\) is not whole, and the two nearest modes only at a tie, \(x = k + \tfrac12\):

Truncate Floor Ceil NearestEven NearestAway −3 −2 −1 0 1 2 3
Where 2.5 and −2.5 go in each mode.

Over a whole range each mode is a staircase. Here the crate's to_rounded turns tenths from −3 to 3 into whole numbers; the dots are the halves:

−202−202Truncate−202Floor−202Ceil−202NearestEven−202NearestAway
Computed by tools/book_plots with Fixed::to_rounded when the site is built.

NearestEven has no bias: over many values its ties go up as often as down. NearestAway and Floor drift, which matters when values are summed.

Exact, once

Every mode rounds the true quotient once. For integer storages there is no float on the way.

A factor with \(\pi\), \(\ln 2\) or a root in it is allowed here, unlike in to. It is multiplied as a 64-bit fixed point within \(2^{-64}\) of the true factor, so the result is the correctly rounded one except within \(|x| \cdot 2^{-64}\) of a rounding step.

Thousandths of a degree to µrad is such a factor, \(\pi / 180\,000 \cdot 10^{6}\):

use exact_units::{Fixed, Rounding, prefix::Micro, units::Radian};

exact_units::scale! {
    /// A thousandth of a degree, as a step of the radian: π / 180 000.
    pub struct MilliDegree = "m°", Exact(1 / 180000, pi 1);
}

// 90°; to() would not compile: π is no fraction
let a: Fixed<Radian, MilliDegree, i32> = Fixed::new(90_000);
let r = a.to_rounded::<Micro, i32>(Rounding::NearestEven).unwrap();
assert_eq!(r.get(), 1_570_796);                               // π/2 rad in µrad

When the result doesn't fit

to_rounded returns None when the rounded result doesn't fit. Two more name another way: Overflow.

Method Too big for the storage
to_rounded None
to_saturating the nearest end
to_wrapping modulo \(2^{\text{bits}}\)
requantize(r, o) rounding and overflow chosen at run time

Proven by the bounds

When the bounds prove that every rounded result fits, into_rounded returns the value itself, not an Option. It doesn't compile where the proof doesn't hold:

use exact_units::{Fixed, Range, Rounding, prefix::{Deci, Milli}, units::Volt};

type Rail = Fixed<Volt, Milli, i16, Range<0, 3300>>;

let dv: Fixed<Volt, Deci, u8> = Rail::bounded(1250).into_rounded(Rounding::NearestEven);
assert_eq!(dv.get(), 12); // 0 ..= 33 dV always fits a u8

At run time

A tool that reads scales from an API model has them as values, not types. Requantizer is the same conversion with the factor a Magnitude and the target a StorageKind, and gives the same number, bit for bit:

use exact_units::{Magnitude, Overflow, Repr, Requantizer, Rounding, StorageKind};

// mV to dV in an i8: the factor is 10⁻²
let q = Requantizer::new(
    &Magnitude::pow10(-2),
    StorageKind::Int { bits: 8, signed: true },
    Rounding::NearestEven,
    Overflow::Checked,
);
assert_eq!(q.apply(Repr::Int(1250)), Some(Repr::Int(12)));

WireWeaver's Python package and its C library run the same conversion with the same results.