From ffe640cecea84380fd11e9a05bb6bc4882fef852 Mon Sep 17 00:00:00 2001 From: Olivier Dony Date: Mon, 14 Nov 2011 19:23:10 +0100 Subject: [PATCH] [IMP] tools.float_*: added tests and docstrings bzr revid: odo@openerp.com-20111114182310-kkzh78uej0uuwhaz --- openerp/addons/base/res/res_currency.py | 23 ++++++--- openerp/addons/base/test/base_test.yml | 68 +++++++++++++++++++++++++ openerp/tools/misc.py | 31 ++++++++--- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/openerp/addons/base/res/res_currency.py b/openerp/addons/base/res/res_currency.py index c0a2bebc104..c67c7d65260 100644 --- a/openerp/addons/base/res/res_currency.py +++ b/openerp/addons/base/res/res_currency.py @@ -128,8 +128,7 @@ class res_currency(osv.osv): def round(self, cr, uid, currency, amount): """Return ``amount`` rounded according to ``currency``'s - rounding rules, also minimizing IEEE-754 floating point - representation errors. + rounding rules. :param browse_record currency: currency for which we are rounding :param float amount: the amount to round @@ -138,12 +137,16 @@ class res_currency(osv.osv): return float_round(amount, precision_rounding=currency.rounding) def compare_amounts(self, cr, uid, currency, amount1, amount2): - """Compare ``amount1`` and ``amount2`` according to ``currency``'s - rounding rules, and return (resp.) -1, 0 or 1, if ``amount1`` - is (resp.) lower than, equal to, or greater than ``amount2``. + """Compare ``amount1`` and ``amount2`` after rounding them according to the + given currency's precision.. + An amount is considered lower/greater than another amount if their rounded + value is different. This is not the same as having a non-zero difference! - For example 1.432 and 1.431 are equal if currency is rounded to - 2 digits, so this method would return 0 + For 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 (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. :param browse_record currency: currency for which we are rounding :param float amount1: first amount to compare @@ -157,6 +160,12 @@ class res_currency(osv.osv): def is_zero(self, cr, uid, currency, amount): """Returns true if ``amount`` is small enough to be treated as zero according to ``currency``'s rounding rules. + + Warning: ``is_zero(amount1-amount2)`` is not always equivalent to + ``compare_amounts(amount1,amount2) == 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 browse_record currency: currency for which we are rounding :param float amount: amount to compare with currency's zero """ diff --git a/openerp/addons/base/test/base_test.yml b/openerp/addons/base/test/base_test.yml index 6ea578a8659..9b686ccb0d3 100644 --- a/openerp/addons/base/test/base_test.yml +++ b/openerp/addons/base/test/base_test.yml @@ -144,3 +144,71 @@ !python {model: res.partner.category}: | self.pool._init = True +- + "Float precision tests: verify that float rounding methods are working correctly" +- + !python {model: res.currency}: | + currency = self.browse(cr, uid, ref('base.EUR')) + def try_round(self, cr, currency, amount, expected): + result = str(self.round(cr, 1, currency, amount)) + assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected) + try_round(self, cr, currency, 2.674,'2.67') + try_round(self, cr, currency, 2.675,'2.68') # in Python 2.7.2, round(2.675,2) gives 2.67 + try_round(self, cr, currency, 0.001,'0.0') + try_round(self, cr, currency, 0.0049,'0.0') # 0.0049 is closer to 0 than to 0.01, so should round down + try_round(self, cr, currency, 0.005,'0.01') # the rule is to round half up + + def try_zero(self, cr, currency, amount, expected): + assert self.is_zero(cr, 1, currency, amount) == expected, "Rounding error: %s should be zero!" % amount + try_zero(self, cr, currency, 0.01, False) + try_zero(self, cr, currency, 0.001, True) + try_zero(self, cr, currency, 0.0046, True) + try_zero(self, cr, currency, 2.68-2.675, False) # 2.68 - 2.675 = 0.005 -> rounds to 0.01 + try_zero(self, cr, currency, 2.68-2.676, True) # 2.68 - 2.675 = 0.004 -> rounds to 0.0 + + def try_compare(self, cr, currency, amount1, amount2, expected): + assert self.compare_amounts(cr, 1, currency, amount1, amount2) == expected, \ + "Rounding error, compare_amounts(%s,%s) should be %s" % (amount1, amount2, expected) + try_compare(self, cr, currency, 0.001, 0.001, 0) + try_compare(self, cr, currency, 0.001, 0.002, 0) + try_compare(self, cr, currency, 2.675, 2.68, 0) + try_compare(self, cr, currency, 2.676, 2.68, 0) + try_compare(self, cr, currency, 2.674, 2.68, -1) + try_compare(self, cr, currency, 3, 2.68, 1) + try_compare(self, cr, currency, 0.01, 0, 1) + + from tools import float_compare, float_is_zero, float_round + def try_round_digits(float_round, amount, expected): + result = str(float_round(amount, precision_digits=3)) + assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected) + try_round_digits(float_round, 2.6745, '2.675') + try_round_digits(float_round, 2.6744, '2.674') + try_round_digits(float_round, 0.0004, '0.0') + + def try_zero_digits(float_is_zero, amount, expected): + assert float_is_zero(amount, precision_digits=3) == expected, "Rounding error: %s should be zero!" % amount + try_zero_digits(float_is_zero, 0.0002, True) + try_zero_digits(float_is_zero, 0.00034, True) + try_zero_digits(float_is_zero, 0.0005, False) + try_zero_digits(float_is_zero, 0.0008, False) + + def try_compare_digits(float_compare, amount1, amount2, expected): + assert float_compare(amount1, amount2, precision_digits=3) == expected, \ + "Rounding error, compare_amounts(%s,%s) should be %s" % (amount1, amount2, expected) + try_compare_digits(float_compare, 0.0003, 0.0004, 0) + try_compare_digits(float_compare, 0.0002, 0.0005, -1) + try_compare_digits(float_compare, 0.0009, 0.0004, 1) + + # specifying 2 precisions is illegal: + try: + float_is_zero(0.01, precision_digits=3, precision_rounding=0.01) + except AssertionError: + pass + try: + float_compare(0.01, 0.02, precision_digits=3, precision_rounding=0.01) + except AssertionError: + pass + try: + float_round(0.01, precision_digits=3, precision_rounding=0.01) + except AssertionError: + pass diff --git a/openerp/tools/misc.py b/openerp/tools/misc.py index ae28567e19b..496767f7c41 100644 --- a/openerp/tools/misc.py +++ b/openerp/tools/misc.py @@ -1210,11 +1210,13 @@ def _float_check_precision(precision_digits=None, precision_rounding=None): def float_round(amount, precision_digits=None, precision_rounding=None): """Return ``amount`` rounded to ``precision_digits`` - decimal digits, minimizing IEEE-754 floating point representation + decimal digits, minimizing IEEE-854 floating point representation errors. Precision must be given by ``precision_digits`` or ``precision_rounding``, not both! - Example on Python 2.7.2:: + + 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 @@ -1241,6 +1243,11 @@ def float_is_zero(amount, precision_digits=None, precision_rounding=None): Precision must be given by ``precision_digits`` or ``precision_rounding``, not both! + Warning: ``float_is_zero(amount1-amount2)`` is not always equivalent to + ``float_compare(amount1,amount2) == 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 float precision_rounding: decimal number representing the minimum non-zero value at the desired precision (for example, 0.01 for a @@ -1253,13 +1260,20 @@ def float_is_zero(amount, precision_digits=None, precision_rounding=None): return abs(float_round(amount, precision_rounding=rounding_factor)) < rounding_factor def float_compare(amount1, amount2, precision_digits=None, precision_rounding=None): - """Compare ``amount1`` and ``amount2`` according - to the given precision. - Precision must be given by ``precision_digits`` or ``precision_rounding``, - not both! + """Compare ``amount1`` and ``amount2`` after rounding them according to the + given precision. An amount is considered lower/greater than another amount + if their rounded value is different. This is not the same as having a + non-zero difference! For 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 (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. + + + Precision must be given by ``precision_digits`` or ``precision_rounding``, + not both! :param int precision_digits: number of decimal digits to round to. :param float precision_rounding: decimal number representing the minimum @@ -1272,9 +1286,10 @@ def float_compare(amount1, amount2, precision_digits=None, precision_rounding=No """ rounding_factor = _float_check_precision(precision_digits=precision_digits, precision_rounding=precision_rounding) + amount1 = float_round(amount1, precision_rounding=rounding_factor) + amount2 = float_round(amount2, precision_rounding=rounding_factor) delta = amount1 - amount2 if float_is_zero(delta, precision_rounding=rounding_factor): return 0 - delta = float_round(delta, precision_rounding=rounding_factor) - return -1 if delta < 0 else 1 + return -1 if delta < 0.0 else 1 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: \ No newline at end of file