diff --git a/openerp/addons/base/test/base_test.yml b/openerp/addons/base/test/base_test.yml index 41c5c8d84e8..fcead834d93 100644 --- a/openerp/addons/base/test/base_test.yml +++ b/openerp/addons/base/test/base_test.yml @@ -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" diff --git a/openerp/osv/fields.py b/openerp/osv/fields.py index 0579677fd5e..ea1b6d93329 100644 --- a/openerp/osv/fields.py +++ b/openerp/osv/fields.py @@ -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: diff --git a/openerp/tools/float_utils.py b/openerp/tools/float_utils.py index 202de89d0cf..5c934114e3c 100644 --- a/openerp/tools/float_utils.py +++ b/openerp/tools/float_utils.py @@ -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