Some Notes About Rounding Algorithms

Table of Contents


Not directly an amateur-radio topic, but I happen to have this small web-server I can use to present discussions about assorted topics. The well-known search-engines of the internet will bring you here, if this is a topic that you find interesting.

Introduction

Every few years I've come across the need to print values rounded to some reasonable number of digits. Values that came from measurements, from averaging processes or from calculations; often values with known or computable errorbars. I always had to search my books, old code repositories or the internet for the relevant algorithms, or for code-fragments that do what I needed. It seemed a good idea to write these notes in a fixed place, so I can find them next time I need to print "97531.02468 ± 14.5678".

Rounding a value to some number of significant decimal digits is a very common operation, and there are therefore many web-sites that try to say something about it; but it is not so trivial as a large fraction of these web-sites suggest. The code fragments below attempt to address the most relevant issues.

The three rounding approaches presented here are:

  • Round a number to N significant decimal digits.
  • Round a number to an E-Series preferred value, such as E24 or E48.
  • Round a number to fit within a given errorbar.

Programming Language

These code fragments are in C, "Plain-C" or "Standard-C", in fact in the 1999 (or newer) version of this standard: C99. These fragments are not C++, C#, Java or any other C-derived programming language. I needed these functions in larger programs that are (for better or for worse) written in plain-C. However, I believe that these functions are clear enough so that translation into any other programming language should be easy. I will leave that as an exercise to the reader.

As any good C source, we have to start with some includes:

// roundings.c :

#include "roundings.h"
#include <math.h>

The header file roundings.h just carries the declarations for the four functions defined below.


Round to N decimal digits

This algorithm probably goes back to early history of computing; Knuth writes in TAoCP and other papers about rounding algorithms and in particular about the advantages of round-to-even. More recent discussions can be found on web-sites as "stackoverflow".

There seem to be two main approaches to determining the most significant decimal digit,

  • Either loop over a divide-by-10 steps, or over multiply by 10 for numbers smaller than 1.0, until the result is just below, or just above, unity.
  • Or take the log10 of the input number, the integer part of this log is one less than the number of decimal digits in the number.

I prefer this last approach.

// roundings.c :

double
roundToNdigits( double const num, int const N )
{
    if ( ! isfinite(num) || num == 0.0 || N>15 )
      return num;

    if ( N<1 )
      return 0.0;  /* What is the best action in this case? */

    /* Number of (non-zero-)digits left of decimal point (possibly negative!) */
    double const digitsLeftOfRadix = ceil( log10( fabs( num )));

    int const digitsToScale = N - (int) digitsLeftOfRadix;

    double const magnitude = pow( 10, digitsToScale );

    long const scaled = lrint( num * magnitude ); /* Default: Round to even */

    return scaled / magnitude ;
}

Of course, "decimal digits" is anyhow problematic concept on a binary computer system: 0.1 cannot be represented (like 1/3 cannot be represented in a decimal system). I will in fact simply state that this 0.1 "is not a problem" :-) Most printing routines will handle this more or less reasonable; In C and similar languages, I like to use: printf("%-.11g", num);. Using smaller ".precision" values quickly causes prints in "e" formatting, using larger values causes the "0.1" representation to become visible.


Round to E-series values

Rounding to N significant digits results in a fairly uneven distribution of values. Look, for example, with N=2, at the jump in possible values around 100: ...,97,98,99,100,110,120,...

We could (in the style of many Digital Multi Meters) slightly mitigate this jump in the possible values by rounding to N=2,5 digits, but that just moves the point at which this jump happens: ...,197,198,199,200,210,220,...

(A long time ago, it was suggested to me that the best location for this jump would be at 2.7 (or "e"). It seems kind of believable (as is any magic related to "e"), but I forgot the exact reasons. And it still leaves a similar step in the value distribution, just at another point.)

A much nicer approach would be to round to values in the so-called E-series of preferred numbers. These numbers are evenly distributed on a logarithmic scale. See for details the Wikipedia entry for E-Series numbers.

// roundings.c :

double
roundToEseries( double const num, int const Eserie )
{
    if (  ! isfinite(num) || num == 0.0 )
      return num;

    if ( Eserie>200 )
      return num;   /* What is the best action in this case? */

    if ( Eserie<1 )
      return 0.0;   /* What is the best action in this case? */

    double const log10num = log10( fabs( num ));

    double const rounded_log10num = nearbyint( Eserie * log10num );

    double const rounded = pow( 10, (rounded_log10num/Eserie));

    int const Ndigits = Eserie<48 ? 2 : 3 ;

    return copysign( roundToNdigits( rounded, Ndigits ), num );
}

/* The circa 5% accuracy of the E24 series is good enough for my usage. */
double
roundToE24series( double const num )
{
    return roundToEseries( num, 24 );
}

In fact the E-series used in electronic components have a few exceptions were the E-serie value is slightly larger or smaller that the logarithmic distribution would suggest. These exception might (historically) be helpful for component production; they have no importance for us. These functions round to the nearest (even) proper logarithmic approximation of an E-series value, ignoring these small exceptions.


Round Inside an Errorbar

One is often faced with a number with a known errorbar around it, for example: X = 1543.5703 +/- 1.5

In such cases, one wants to round the main value to a number of digits given by the size of the errorbar. So something like: X = 1543.6 +/- 1.5

The algorithm is similar to "Rounding to N Decimal Digits", except that the errorbar value is used to determine the actual digits left of the decimal point.

// roundings.c :

double
roundInsideErrorbar( double const num, double const e)
{
    if ( ! isfinite(num) || num == 0.0 )
      return num;

    if ( ! isfinite(e) || e == 0.0 )
      return num; /* What is the best action in this case? */

    /* Number of (non-zero-) digits left of decimal point */
    double const digitsLeftOfRadix = ceil( log10( fabs( e )));

    // Two digits overlap with the errorbar.
    double const magnitude = pow( 10, 2- (int)digitsLeftOfRadix );

    long const scaled = lrint( num * magnitude );

    return scaled / magnitude ;
}

Since the main value tends to be significant larger than the errorbar, it makes little sense to round it to an E-series value. On the other hand, the errorbar itself is a prime example for rounding to such an E-series value.


The Makefile

I like to pull all this together with make. The relevant Makefile fragments could look like this:

# Makefile 

# Should also work with more modern C standards: C11, C17, c18, C23
CFLAGS+=-std=c99  -Wall  -Wextra  -pedantic  -O3

roundings.o :roundings.c roundings.h
	$(CC) $(CFLAGS)  -c   $< -o $@ -lm 

testroundings:roundings.c roundings.h
	$(CC) $(CFLAGS)   -DMAKE_TESTS   $< -o $@ -lm 

run_testroundings: testroundings FORCE
	./testroundings -v

cl:clean
clean: FORCE
	rm -rf *~ *.o testroundings

FORCE:
.PHONY: FORCE clean cl run_testroundings

Adjust as appropriate for your make- or build- environment.


Testing

The source file also contains quite a number of tests; a special one is this code that prints the rounded values, given the input value in the introduction at the start of this web-page:

// roundings.c :

/* - --- --- -   -   - --- --- --- ---   - ---   --- --- - ---   - --- --- - */
/* Some tests */

#ifdef MAKE_TESTS

#include <assert.h>
#include <stdlib.h>
#include <stdio.h>

int
main( int argc, char **argv )
{
    if ( argc==2 && argv++ && *argv && **argv=='-' && *(1+*argv)=='v' )
    {
	double const avg = 97531.02468;
	double const err = 14.5678;

	double const Rerr = roundToE24series( err );
	double const Ravg = roundInsideErrorbar( avg, Rerr);

	printf ( "The computed value and errorbar: %-.11g +/- %-.11g \n", avg, err );
	printf ( "could be rounded and printed as: %-.11g +/- %-.11g \n", Ravg, Rerr );
    }
// ...

Running this code gives:

$ ./testroundings -v
The computed value and errorbar: 97531.02468 +/- 14.5678 
could be rounded and printed as: 97531 +/- 15

Good enough, for my purposes.


Download

The C source file, C header file and a minimal makefile as described above can be downloaded here:
roundings.tgz.
(This is a compressed tar (tgz) archive, unpack it with one of these programs.)


Contact information

For questions and comments, improvements and bug-fixes, blame and praise, contact me at the email-address: rounding <at> krom <dot> eu


Back to

The Fine Print

I'm a fan of the GPL, and in particular the GPLv3, license. But the algorithms and code-fragments presented here are cobbled together from many sources; it seems unfair to release it under a stricter license. I will therefore only claim copyright on the work as presented here; all this work is freely usable. More accurately:

Author: Jon Krom : See Colophon

Created: 2023-03-21 Tue 18:02

Emacs 25.1.1 (Org mode 8.2.10)

Validate