[IMP] float_utils: simplified code, added float_repr

lp bug: https://launchpad.net/bugs/882036 fixed

bzr revid: odo@openerp.com-20111220234740-kotcgoz3opcbkx4b
This commit is contained in:
Olivier Dony 2011-12-21 00:47:40 +01:00
parent 707bf0d8aa
commit 759ccd9845
3 changed files with 92 additions and 95 deletions

View File

@ -148,16 +148,20 @@
"Float precision tests: verify that float rounding methods are working correctly via res.currency"
-
!python {model: res.currency}: |
from tools import float_repr
from math import log10
currency = self.browse(cr, uid, ref('base.EUR'))
def try_round(amount, expected, self=self, cr=cr, currency=currency):
result = str(self.round(cr, 1, currency, amount))
def try_round(amount, expected, self=self, cr=cr, currency=currency, float_repr=float_repr,
log10=log10):
digits = max(0,-int(log10(currency.rounding)))
result = float_repr(self.round(cr, 1, currency, amount), precision_digits=digits)
assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
try_round(2.674,'2.67')
try_round(2.675,'2.68') # in Python 2.7.2, round(2.675,2) gives 2.67
try_round(-2.675,'-2.68') # in Python 2.7.2, round(2.675,2) gives 2.67
try_round(0.001,'0.0')
try_round(-0.001,'-0.0')
try_round(0.0049,'0.0') # 0.0049 is closer to 0 than to 0.01, so should round down
try_round(0.001,'0.00')
try_round(-0.001,'-0.00')
try_round(0.0049,'0.00') # 0.0049 is closer to 0 than to 0.01, so should round down
try_round(0.005,'0.01') # the rule is to round half away from zero
try_round(-0.005,'-0.01') # the rule is to round half away from zero
@ -195,16 +199,17 @@
"Float precision tests: verify that float rounding methods are working correctly via tools"
-
!python {model: res.currency}: |
from tools import float_compare, float_is_zero, float_round
def try_round(amount, expected, precision_digits=3, float_round=float_round):
result = str(float_round(amount, precision_digits=precision_digits))
from tools import float_compare, float_is_zero, float_round, float_repr
def try_round(amount, expected, precision_digits=3, float_round=float_round, float_repr=float_repr):
result = float_repr(float_round(amount, precision_digits=precision_digits),
precision_digits=precision_digits)
assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
try_round(2.6745, '2.675')
try_round(-2.6745, '-2.675')
try_round(2.6744, '2.674')
try_round(-2.6744, '-2.674')
try_round(0.0004, '0.0')
try_round(-0.0004, '-0.0')
try_round(0.0004, '0.000')
try_round(-0.0004, '-0.000')
try_round(357.4555, '357.456')
try_round(-357.4555, '-357.456')
try_round(457.4554, '457.455')
@ -212,16 +217,15 @@
# Extended float range test, inspired by Cloves Almeida's test on bug #882036.
fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555]
expecteds = ['.0', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
precisions = [2, 2, 2, 2, 2, 2, 3, 4]
# We can't go higher than 12 significant digits with a fractional part, so
# that means max magnitude = 5 with precision 4 if sample values (x) go up to 10000
# because that gives 5 + 4 + 4 = 13 significant digits.
for magnitude in range(5):
# Note: max precision for double floats is 53 bits of precision or
# 17 significant decimal digits
for magnitude in range(7):
for i in xrange(len(fractions)):
frac, exp, prec = fractions[i], expecteds[i], precisions[i]
for sign in [-1,1]:
for x in xrange(0,10000,34):
for x in xrange(0,10000,97):
n = x * 10**magnitude
f = sign * (n + frac)
f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp
@ -252,15 +256,16 @@
try_compare(657.4444, 657.445, -1)
try_compare(-657.4444, -657.445, 1)
# Rounding to different precision_roundings
def try_round(amount, expected, precision_rounding=None, float_round=float_round):
result = str(float_round(amount, precision_rounding=precision_rounding))
# Rounding to unusual rounding units (e.g. coin values)
def try_round(amount, expected, precision_rounding=None, float_round=float_round, float_repr=float_repr):
result = float_repr(float_round(amount, precision_rounding=precision_rounding),
precision_digits=2)
assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
try_round(-457.4554, '-457.45', precision_rounding=0.05)
try_round(457.444, '457.5', precision_rounding=0.5)
try_round(457.3, '455.0', precision_rounding=5)
try_round(457.5, '460.0', precision_rounding=5)
try_round(457.1, '456.0', precision_rounding=3)
try_round(457.444, '457.50', precision_rounding=0.5)
try_round(457.3, '455.00', precision_rounding=5)
try_round(457.5, '460.00', precision_rounding=5)
try_round(457.1, '456.00', precision_rounding=3)
-
"Float precision tests: verify that invalid parameters are forbidden"

View File

@ -45,7 +45,7 @@ import openerp
import openerp.netsvc as netsvc
import openerp.tools as tools
from openerp.tools.translate import _
from openerp.tools import float_round
from openerp.tools import float_round, float_repr
def _symbol_set(symb):
if symb == None or symb == False:
@ -240,7 +240,8 @@ class float(_column):
if self.digits_compute:
self.digits = self.digits_compute(cr)
precision, scale = self.digits
self._symbol_set = ('%s', lambda x: str(float_round(__builtin__.float(x or 0.0), precision_digits=scale)))
self._symbol_set = ('%s', lambda x: float_repr(float_round(__builtin__.float(x or 0.0), precision_digits=scale),
precision_digits=scale))
class date(_column):
_type = 'date'
@ -994,7 +995,8 @@ class function(_column):
if self.digits_compute:
self.digits = self.digits_compute(cr)
precision, scale = self.digits
self._symbol_set = ('%s', lambda x: str(float_round(__builtin__.float(x or 0.0), precision_digits=scale)))
self._symbol_set = ('%s', lambda x: float_repr(float_round(__builtin__.float(x or 0.0), precision_digits=scale),
precision_digits=scale))
def search(self, cr, uid, obj, name, args, context=None):
if not self._fnct_search:

View File

@ -19,15 +19,7 @@
#
##############################################################################
import logging
import math
from decimal import Decimal, ROUND_HALF_UP
# When a number crosses this threshold, significant decimal
# digits may be lost when trying to render the float value, due to
# Python's float implementation.
# e.g. str(10060000.45556) == '10060000.4556' => lost 1 digit!
SIGNIFICANT_DIGITS_SCALE_LIMIT = math.log(10**12, 2) # 10**12 ~= 2**39.86
def _float_check_precision(precision_digits=None, precision_rounding=None):
assert (precision_digits is not None or precision_rounding is not None) and \
@ -40,20 +32,12 @@ def _float_check_precision(precision_digits=None, precision_rounding=None):
def float_round(value, precision_digits=None, precision_rounding=None):
"""Return ``value`` rounded to ``precision_digits``
decimal digits, minimizing IEEE-754 floating point representation
errors.
errors, and applying HALF-UP (away from zero) tie-breaking rule.
Precision must be given by ``precision_digits`` or ``precision_rounding``,
not both!
To illustrate how this is different from the default round() builtin,
here is an example (depends on Python version, here is for v2.7.2 x64)::
>>> round_float(2.675)
2.68
>>> round(2.675,2)
2.67
:param float value: the value to round
:param int precision_digits: number of decimal digits to round to.
:param int precision_digits: number of fractional digits to round to.
:param float precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
@ -62,58 +46,51 @@ def float_round(value, precision_digits=None, precision_rounding=None):
rounding_factor = _float_check_precision(precision_digits=precision_digits,
precision_rounding=precision_rounding)
if rounding_factor == 0 or value == 0: return 0.0
# scale up by rounding_factor, in order to implement rounding to arbitrary
# `units` or 'rounding_factors'.
# Example: if rounding_factor is 0.5, 1.3 should round to 1.5
# So we'll do this: scaled_value = 1.3 / 0.5 = 2.6
# int_rounded = round(2.6) = 3
# result = 3 * 0.5 = 1.5
# Also, .5 is a binary fraction, so this automatically solves some tricky
# cases when rounding_factor is a negative power of 10. E.g 2.6745 is
# difficult to round to 0.001 because it does not have an exact IEEE754
# representation, but 2674.5 is simple to round to 2675 because both
# are exactly represented.
scaled_value = value / rounding_factor
# Despite the advantage of rounding to .5 binary fractions, we still need
# to add a small epsilon value to take care of cases where the float repr
# is slightly too far below .5 to properly round *up* automatically.
# That epsilon needs to be scaled according to the order of magnitude of
# the value. (Credit: discussed with several community members on bug 882036)
epsilon_scale = math.log(abs(scaled_value), 2)
frac_part, _ = math.modf(scaled_value)
if frac_part and epsilon_scale > SIGNIFICANT_DIGITS_SCALE_LIMIT:
print 'Float rounding of %r to %r precision requires too many '\
'significant digits, a loss of precision may occur in the '\
'least significant digits' % (value,rounding_factor)
logging.getLogger('float_utils')\
.warning('Float rounding of %r to %r precision requires too many '
'significant digits, a loss of precision may occur in the '
'least significant digits', value, rounding_factor)
epsilon = 2**(epsilon_scale-50)
scaled_value += cmp(scaled_value,0) * epsilon
rounded_value = round(scaled_value)
result = rounded_value * rounding_factor
# NORMALIZE - ROUND - DENORMALIZE
# In order to easily support rounding to arbitrary 'steps' (e.g. coin values),
# we normalize the value before rounding it as an integer, and de-normalize
# after rounding: e.g. float_round(1.3, precision_rounding=.5) == 1.5
# TIE-BREAKING: HALF-UP
# We want to apply HALF-UP tie-breaking rules, i.e. 0.5 rounds away from 0.
# Due to IEE754 float/double representation limits, the approximation of the
# real value may be slightly below the tie limit, resulting in an error of
# 1 unit in the last place (ulp) after rounding.
# For example 2.675 == 2.6749999999999998.
# To correct this, we add a very small epsilon value, scaled to the
# the order of magnitude of the value, to tip the tie-break in the right
# direction.
# Credit: discussion with OpenERP community members on bug 882036
normalized_value = value / rounding_factor # normalize
epsilon_magnitude = math.log(abs(normalized_value), 2)
epsilon = 2**(epsilon_magnitude-53)
normalized_value += cmp(normalized_value,0) * epsilon
rounded_value = round(normalized_value) # round to integer
result = rounded_value * rounding_factor # de-normalize
return result
def float_is_zero(value, precision_digits=None, precision_rounding=None):
"""Returns true if ``value`` is small enough to be treated as
zero at the given precision (smaller than the given *epsilon*).
zero at the given precision (smaller than the corresponding *epsilon*).
The precision (``10**-precision_digits`` or ``precision_rounding``)
is used as the zero *epsilon*: values less than that are considered
to be zero.
Precision must be given by ``precision_digits`` or ``precision_rounding``,
not both! Here the precision (``10**-precision_digits`` or
``precision_rounding``) is used as the zero *epsilon*: values smaller
than that are considered to be zero.
not both!
Warning: ``float_is_zero(value1-value2)`` is not always equivalent to
Warning: ``float_is_zero(value1-value2)`` is not equivalent to
``float_compare(value1,value2) == 0``, as the former will round after
computing the difference, while the latter will round before, giving
different results for e.g. 0.006 and 0.002 at 2 digits precision.
:param int precision_digits: number of decimal digits to round to.
:param int precision_digits: number of fractional digits to round to.
:param float precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
:param float value: value to compare with currency's zero
:return: True if ``value`` is considered 0
:param float value: value to compare with the precision's zero
:return: True if ``value`` is considered zero
"""
epsilon = _float_check_precision(precision_digits=precision_digits,
precision_rounding=precision_rounding)
@ -129,16 +106,16 @@ def float_compare(value1, value2, precision_digits=None, precision_rounding=None
Example: 1.432 and 1.431 are equal at 2 digits precision,
so this method would return 0
However 0.006 and 0.002 are considered different (method returns 1) because
they respectively round to 0.01 and 0.0, even though 0.006-0.002 = 0.004
which would be considered zero at 2 digits precision.
However 0.006 and 0.002 are considered different (this method returns 1)
because they respectively round to 0.01 and 0.0, even though
0.006-0.002 = 0.004 which would be considered zero at 2 digits precision.
Warning: ``float_is_zero(value1-value2)`` is not always equivalent to
Warning: ``float_is_zero(value1-value2)`` is not equivalent to
``float_compare(value1,value2) == 0``, as the former will round after
computing the difference, while the latter will round before, giving
different results for e.g. 0.006 and 0.002 at 2 digits precision.
:param int precision_digits: number of decimal digits to round to.
:param int precision_digits: number of fractional digits to round to.
:param float precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
@ -155,8 +132,20 @@ def float_compare(value1, value2, precision_digits=None, precision_rounding=None
if float_is_zero(delta, precision_rounding=rounding_factor): return 0
return -1 if delta < 0.0 else 1
def float_repr(value, precision_digits):
"""Returns a string representation of a float with the
the given number of fractional digits. This should not be
used to perform a rounding operation (this is done via
:meth:`~.float_round`), but only to produce a suitable
string representation for a float.
:param int precision_digits: number of fractional digits to
include in the output
"""
# Can't use str() here because it seems to have an intrisic
# rounding to 12 significant digits, which causes a loss of
# precision. e.g. str(123456789.1234) == str(123456789.123)!!
return ("%%.%sf" % precision_digits) % value
if __name__ == "__main__":
@ -168,20 +157,21 @@ if __name__ == "__main__":
def try_round(amount, expected, precision_digits=3):
global count, errors; count += 1
result = float_round(amount, precision_digits=precision_digits)
if str(result) != expected:
result = float_repr(float_round(amount, precision_digits=precision_digits),
precision_digits=precision_digits)
if result != expected:
errors += 1
print '###!!! Rounding error: got %s or %s, expected %s' % (str(result), repr(result), expected)
print '###!!! Rounding error: got %s , expected %s' % (result, expected)
# Extended float range test, inspired by Cloves Almeida's test on bug #882036.
fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555]
expecteds = ['.0', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
precisions = [2, 2, 2, 2, 2, 2, 3, 4]
for magnitude in range(5):
for magnitude in range(7):
for i in xrange(len(fractions)):
frac, exp, prec = fractions[i], expecteds[i], precisions[i]
for sign in [-1,1]:
for x in xrange(0,10000,17):
for x in xrange(0,10000,97):
n = x * 10**magnitude
f = sign * (n + frac)
f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp