Welcome, guest | Sign In | My Account | Store | Cart

Method to find significant digits for any given number. Accounts for scientific notation. Returns a numeric string.

A unit test is included.

Python, 104 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import unittest

def makeSigDigs(num, digits, debug=False):
    """Return a numeric string with significant digits of a given number.
    
    Arguments:
      num -- a numeric value
      digits -- how many significant digits (int)
      debug -- boolean; set to True for verbose mode
    """
    notsig = ['0', '.', '-', '+', 'e'] # not significant
    pad_zeros_left = '' # zeros to pad immed. left of the decimal
    pad_zeros_right = '' # zeros to pad immed. right of the decimal
    pad_zeros_last = '' # zeros to pad after last number after decimal
    str_left = '' # string to prepend to left of zeros and decimal
    str_right = '' # string to append to right of decimal and zeros
    dec = '.' # the decimal
    e_idx = None
    e_str = ''
    num = float(num)
    if debug: print "%s at %s digits:" % (repr(num), digits)
    for n in repr(num):
        if n not in notsig: # ignore zeros and punctuation
            first_idx = repr(num).find(n) # index of first digit we care about
            if debug: print "\tfirst digit at %s" % (first_idx)
            break
    try: first_idx # If it doesn't exist, then we're looking at 0.0
    except UnboundLocalError:
        return '0.0'
    try: e_idx = repr(num).index('e') # get index of e if in scientific notation
    except: pass
    if debug: print "\te at: %s" % (e_idx)
    dec_idx = repr(num).find('.') # index of the decimal
    if debug: print "\tdecimal at %s" % (dec_idx)
    if dec_idx < first_idx:
        """All sigdigs to right of decimal '0.033'
        """
        if debug: print "\tdigits are right of decimal."
        last_idx = first_idx + digits -1
        if last_idx+1 > len(repr(num)[0:e_idx]): # in case we need extra zeros at the end
            pad_zeros_last = '0'*(last_idx+1 - len(repr(num)[0:e_idx]))
        if e_idx and last_idx >= e_idx: # fix last_idx if it picks up the 'e'
            last_idx = e_idx-1
        pad_zeros_left = '0'*1
        pad_zeros_right = '0'*(first_idx - dec_idx - 1)
        str_right = repr(num)[first_idx:last_idx+1]
    elif dec_idx > first_idx + digits - 1:
        """All sigdigs to left of decimal. '3300.0'
        """
        if debug: print "\tdigits are left of decimal."
        last_idx = first_idx + digits - 1
        if e_idx and last_idx >= e_idx: # fix last_idx if it picks up the 'e'
            last_idx = e_idx-1
        str_left = repr(num)[first_idx]
        str_right = repr(num)[first_idx+1:last_idx+1]+'e+'+str(dec_idx-1-first_idx)
    else:
        """Sigdigs straddle the decimal '3.300'
        """
        if debug: print "\tnumber straddles decimal."
        last_idx = first_idx + digits # an extra place for the decimal
        if last_idx+1 > len(repr(num)[0:e_idx]): # in case we need extra zeros at the end
            pad_zeros_last = '0'*(last_idx+1 - len(repr(num)[0:e_idx]))
        if e_idx and last_idx >= e_idx: # fix last_idx if it picks up the 'e'
            last_idx = e_idx-1
        str_left = repr(num)[first_idx:dec_idx]
        str_right = repr(num)[dec_idx+1:last_idx + 1]
    if e_idx:
        e_str = repr(num)[e_idx:]
    if debug: print "\tlast digit at %s" % (last_idx)
    if debug: print "\t%s %s %s %s %s %s %s" % (str_left or '_',
                                                pad_zeros_left or '_',
                                                dec or '_',
                                                pad_zeros_right or '_',
                                                str_right or '_',
                                                pad_zeros_last or '_',
                                                e_str or '_')
    sig_string = str_left+pad_zeros_left+dec+pad_zeros_right+str_right+pad_zeros_last+e_str
    if debug: print "\tsignificant: %s\n" % (sig_string)
    return sig_string



class utMakeSigDigs(unittest.TestCase):
    knownValues = [[333.333, 4, '333.3'],
                   [33.0, 2, '3.3e+1'],
                   [333.33, 2, '3.3e+2'],
                   [33300.00, 4, '3.330e+4'],
                   [0.0033333, 3, '0.00333'],
                   [3.3e-10, 2, '3.3e-10'],
                   [0.0001, 2, '0.00010'],
                   [3.3e-10, 3, '3.30e-10'],
                   [1.0000000, 6, '1.00000'],
                   [1.00000001591, 6, '1.00000'],
                   [33330000000000000000.0, 6, '3.33300e+19'],
                   [33330000000000000000.03, 6, '3.33300e+19']
                   ]
    def testKnownValues(self):
        """MakeSigDigs should return known values for known inputs.
        """
        for el in self.knownValues:
            self.assertEqual(makeSigDigs(el[0], el[1], debug=True), el[2])

if __name__ == "__main__":
    unittest.main()

Significant digits are important in many scientific and engineering applications, and anything involving measurement and calculation.

For an introduction to significant digits: http://www.physics.uoguelph.ca/tutorials/sig_fig/SIG_dig.htm

Known Issues:

If all the significant digits are to the left of the decimal, then it always returns in scientific notation. That is, 3300 becomes 3.3e+3 and 33 becames 3.3e+1. This is a feature, not a bug. ;) Actually, I discovered this rule late in the process, and I really didn't feel like writing another rule to make 33.0 become 33. :P It is ugly, but technically correct.

I suppose some people might actually prefer that all cases returned scientific notation. However, I'm not one of those people.

The unit test could use more test cases. Bug reports and refactoring suggestions are welcome--the implementation is rather byzantine.

Disclaimer:

I wrote this mainly by memory from what I learned in high school physics. I might have missed something, because Mr. Rast was always docking me for getting my sigdigs wrong. :P

Updates:

Fixed (or rather, caught) an UnboundLocalError that raised if the given number was zero. It now returns the string '0.0', which seems correct. Any dissenters?

5-Sep-2007: So, it's kind of embarrassing still having this recipe out here. I will clean it up one of these days. Until then, see the comments below for something actually useful.

7 comments

Alexander Semenov 16 years, 8 months ago  # | flag

Your code is monstrous, if I will need this I'll write that.

def myMakeSigDigs(num, digits, debug=False):
    digs, order = ('%.20e'%num).split('e')
    order = int(order)
    if type(num) is long: digs = str(num) # Not needed for current tests
    digs = (digs.replace('.', '') + '0'*digits)[:digits]

    if debug: print 'num=%r, order=%d, digits=%s'%(num, order, digs)

    if 0&lt;=order&lt;5 and order&lt;len(digs)-1:
        return '%s.%s'%(digs[:order+1], digs[order+1:])
    elif -5&lt;=order&lt;0:
        return '0.%s%s'%('0'*(-order-1), digs)
    else:
        return '%s.%se%+d'%(digs[0], digs[1:], order)

This code passes your unit test. If you work only with floats, and don't want add to tests

[1234567890123456789012L, 19, '1.234567890123456789e+21']

code may be even one line shorter (see comments).

Alexander Semenov 16 years, 8 months ago  # | flag

Just for fun. when not using longs calculating order and digits can be written with following one-liner: digs, order = [[lambda x, d=digits: (x.replace('.', '') + '0'*d)[:d], int][func](arg) for func, arg in enumerate(('%.20e'%num).split('e'))] I didn't saw such trick before. It is a map() which applies different functions to record fields.

Alexander Semenov 16 years, 8 months ago  # | flag

minor bug in your code. When I ran this recipe i got:

======================================================================
FAIL: MakeSigDigs should return known values for known inputs.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Usr\Sav\wrk\prj\digs\digicalc.py", line 118, in testKnownValues
    self.assertEqual(makeSigDigs(el[0], el[1], debug=True), el[2])
AssertionError: '3.3e-010' != '3.3e-10'

----------------------------------------------------------------------
David Eyk (author) 16 years, 8 months ago  # | flag

Thanks for the tips. When I have a moment, I'll see if I can refactor my code based on your ideas. I'm fairly new with python, hence the monstrosity. ;) Thanks.

Brian Boonstra 15 years, 4 months ago  # | flag

Neither of the above recipes actually works. When asked for two significant digits of 0.9999, both of the above recipes return the wrong result of 0.99, rather than the correct result of 1.0.

Brian Boonstra 15 years, 2 months ago  # | flag

Better for some. Try Tim Peters' solution http://mail.python.org/pipermail/tutor/2004-July/030324.html

John Hill 13 years, 9 months ago  # | flag

Love the test case, what about (0.0009, 2)? Alexander,

I decided to try your improved version before trying the original version (which looks more like something I wrote).

However: myMakeSigDigs(0.0009, 2) -> 0.00089, which I submit will not do. I would have expected to see 0.00090 the reason this matters to me is that I work with reports from chemical analysis, and folks will sometimes switch from mg/L to ug/L or vice versa, in the latter case they would expect 0.00090 to become 0.90 and 0.89 is not a good match.

Created by David Eyk on Thu, 17 Mar 2005 (PSF)
Python recipes (4591)
David Eyk's recipes (1)

Required Modules

Other Information and Tasks