Merge lp:~openerp-dev/openobject-server/trunk-float-rounding-odo into lp:openobject-server

Proposed by Olivier Dony (Odoo)
Status: Merged
Merged at revision: 3911
Proposed branch: lp:~openerp-dev/openobject-server/trunk-float-rounding-odo
Merge into: lp:openobject-server
Diff against target: 508 lines (+406/-18)
6 files modified
openerp/addons/base/res/res_currency.py (+42/-8)
openerp/addons/base/test/base_test.yml (+160/-0)
openerp/osv/fields.py (+16/-9)
openerp/tools/__init__.py (+1/-0)
openerp/tools/float_utils.py (+186/-0)
openerp/tools/misc.py (+1/-1)
To merge this branch: bzr merge lp:~openerp-dev/openobject-server/trunk-float-rounding-odo
Reviewer Review Type Date Requested Status
Vo Minh Thu (community) Approve
Lorenzo Battistini (community) Abstain
Review via email: mp+82206@code.launchpad.net

Description of the change

- Extracted res.currency's float computation methods to tools, so they can be reused cleanly for other floating-point operations.
- Also added a compare() method to allow for easy comparison of 2 float values, as suggested by Ferdinand on bug 865387
- Added tests and docstrings to explain the logic.

Watch out for compare(), because compare(amount1-amount2) == 0 is NOT the same as is_zero(amount1-amount2), this is explained in the docstrings and test. I think this is correct and the desired behavior.

The utility methods used in the YAML tests take redundant arguments due to idiotic namespace issues, feel free to suggest alternatives.

To post a comment you must log in.
Revision history for this message
Ferdinand (office-chricar) wrote :

I hope this rounding methods will be used to replace the problematic checks
not an easy task to find all occurencies

Revision history for this message
Xavier (Open ERP) (xmo-deactivatedaccount) wrote :

That's kind-of nitpicky, but in `float_is_zero` I think `rounding_factor` should be renamed `epsilon`, as that's the term usually used for floating-point relative errors: http://en.wikipedia.org/wiki/Machine_epsilon

Revision history for this message
Cloves Almeida (cjalmeida) wrote :

"misc.py" is over 1500 lines already. Since this is to be used everywhere, maybe a "tools/float.py" is a better approach?

Revision history for this message
Ferdinand (office-chricar) wrote :

Have checked with data from
https://bugs.launchpad.net/openobject-server/+bug/882036/comments/19
and it works nicely
I attach a patched for base_test.yml with extended tests and a bit more verboseitiy
here
https://bugs.launchpad.net/openobject-server/+bug/882036/+attachment/2603081/+files/base_test.yml.20111120.patch

Revision history for this message
Lorenzo Battistini (elbati) wrote :

Hello Olivier,

I think I found an issue:

float_round(17.3544, precision_rounding=0.01)
17.35

review: Needs Fixing
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

> I think I found an issue:
>
> float_round(17.3544, precision_rounding=0.01)
> 17.35

That's the expected result as far as I can see. Perhaps you meant to test with precision_rounding=0.1, in which case you'll indeed end up with 17.4, which is correct. You can confirm with Decimal if you're not sure:

 >>> from decimal import Decimal, ROUND_HALF_UP
 >>> Decimal('17.3544').quantize(Decimal('0.01'),rounding=ROUND_HALF_UP)
 Decimal('17.35')
 >>> Decimal('17.3544').quantize(Decimal('0.1'),rounding=ROUND_HALF_UP)
 Decimal('17.4')
 >>>

Anyway the code still needs to be updated to cover more tricky half-up cases, as discussed on bug 882036.

Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

> "misc.py" is over 1500 lines already. Since this is to be used everywhere,
> maybe a "tools/float.py" is a better approach?

Good point, moved it to tools/float_utils.py, thanks!

Revision history for this message
Lorenzo Battistini (elbati) wrote :

>>
>> float_round(17.3544, precision_rounding=0.01)
>> 17.35
>
> That's the expected result as far as I can see.

Sorry Olivier, you are right. I wrote it too quickly and had another
thing in mind.

Revision history for this message
Lorenzo Battistini (elbati) :
review: Abstain
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

I think I'm done with the final changes here:
- tie-breaking epsilon has been implemented similarly to what was discussed on bug bug 882036 in order to perform proper HALF-UP rounding
- an additional float_repr method was added in float_utils to render a float to a str, as the default str() and repr() do not do what we need. repr() will not round appropriately, and str() will round too eagerly when there are more than 12 significant digits, for some reason.
- the ORM has been updated to properly apply rounding before persisting a value (this is really what bug 882036 was about). It passes the float values to postgres using float_repr, to avoid any precision loss.
- YAML tests updated to include thousands of cases from low to high magnitudes + a test of float round-trip via the database too

Revision history for this message
Vo Minh Thu (thu) wrote :

I haven't spotted anything obviously stupid.

Tests could be moved to openerp.tests but this can wait until the runbot really run those.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'openerp/addons/base/res/res_currency.py'
--- openerp/addons/base/res/res_currency.py 2011-10-11 16:34:35 +0000
+++ openerp/addons/base/res/res_currency.py 2011-12-21 01:14:24 +0000
@@ -24,7 +24,7 @@
24from osv import fields, osv24from osv import fields, osv
25import tools25import tools
2626
27from tools.misc import currency27from tools import float_round, float_is_zero, float_compare
28from tools.translate import _28from tools.translate import _
2929
30CURRENCY_DISPLAY_PATTERN = re.compile(r'(\w+)\s*(?:\((.*)\))?')30CURRENCY_DISPLAY_PATTERN = re.compile(r'(\w+)\s*(?:\((.*)\))?')
@@ -127,15 +127,49 @@
127 return [(x['id'], tools.ustr(x['name']) + (x['symbol'] and (' (' + tools.ustr(x['symbol']) + ')') or '')) for x in reads]127 return [(x['id'], tools.ustr(x['name']) + (x['symbol'] and (' (' + tools.ustr(x['symbol']) + ')') or '')) for x in reads]
128128
129 def round(self, cr, uid, currency, amount):129 def round(self, cr, uid, currency, amount):
130 if currency.rounding == 0:130 """Return ``amount`` rounded according to ``currency``'s
131 return 0.0131 rounding rules.
132 else:132
133 # /!\ First member below must be rounded to full unit!133 :param browse_record currency: currency for which we are rounding
134 # Do not pass a rounding digits value to round()134 :param float amount: the amount to round
135 return round(amount / currency.rounding) * currency.rounding135 :return: rounded float
136 """
137 return float_round(amount, precision_rounding=currency.rounding)
138
139 def compare_amounts(self, cr, uid, currency, amount1, amount2):
140 """Compare ``amount1`` and ``amount2`` after rounding them according to the
141 given currency's precision..
142 An amount is considered lower/greater than another amount if their rounded
143 value is different. This is not the same as having a non-zero difference!
144
145 For example 1.432 and 1.431 are equal at 2 digits precision,
146 so this method would return 0.
147 However 0.006 and 0.002 are considered different (returns 1) because
148 they respectively round to 0.01 and 0.0, even though
149 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision.
150
151 :param browse_record currency: currency for which we are rounding
152 :param float amount1: first amount to compare
153 :param float amount2: second amount to compare
154 :return: (resp.) -1, 0 or 1, if ``amount1`` is (resp.) lower than,
155 equal to, or greater than ``amount2``, according to
156 ``currency``'s rounding.
157 """
158 return float_compare(amount1, amount2, precision_rounding=currency.rounding)
136159
137 def is_zero(self, cr, uid, currency, amount):160 def is_zero(self, cr, uid, currency, amount):
138 return abs(self.round(cr, uid, currency, amount)) < currency.rounding161 """Returns true if ``amount`` is small enough to be treated as
162 zero according to ``currency``'s rounding rules.
163
164 Warning: ``is_zero(amount1-amount2)`` is not always equivalent to
165 ``compare_amounts(amount1,amount2) == 0``, as the former will round after
166 computing the difference, while the latter will round before, giving
167 different results for e.g. 0.006 and 0.002 at 2 digits precision.
168
169 :param browse_record currency: currency for which we are rounding
170 :param float amount: amount to compare with currency's zero
171 """
172 return float_is_zero(amount, precision_rounding=currency.rounding)
139173
140 def _get_conversion_rate(self, cr, uid, from_currency, to_currency, context=None):174 def _get_conversion_rate(self, cr, uid, from_currency, to_currency, context=None):
141 if context is None:175 if context is None:
142176
=== modified file 'openerp/addons/base/test/base_test.yml'
--- openerp/addons/base/test/base_test.yml 2011-07-28 08:27:52 +0000
+++ openerp/addons/base/test/base_test.yml 2011-12-21 01:14:24 +0000
@@ -144,3 +144,163 @@
144 !python {model: res.partner.category}: |144 !python {model: res.partner.category}: |
145 self.pool._init = True145 self.pool._init = True
146146
147-
148 "Float precision tests: verify that float rounding methods are working correctly via res.currency"
149-
150 !python {model: res.currency}: |
151 from tools import float_repr
152 from math import log10
153 currency = self.browse(cr, uid, ref('base.EUR'))
154 def try_round(amount, expected, self=self, cr=cr, currency=currency, float_repr=float_repr,
155 log10=log10):
156 digits = max(0,-int(log10(currency.rounding)))
157 result = float_repr(self.round(cr, 1, currency, amount), precision_digits=digits)
158 assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
159 try_round(2.674,'2.67')
160 try_round(2.675,'2.68') # in Python 2.7.2, round(2.675,2) gives 2.67
161 try_round(-2.675,'-2.68') # in Python 2.7.2, round(2.675,2) gives 2.67
162 try_round(0.001,'0.00')
163 try_round(-0.001,'-0.00')
164 try_round(0.0049,'0.00') # 0.0049 is closer to 0 than to 0.01, so should round down
165 try_round(0.005,'0.01') # the rule is to round half away from zero
166 try_round(-0.005,'-0.01') # the rule is to round half away from zero
167
168 def try_zero(amount, expected, self=self, cr=cr, currency=currency):
169 assert self.is_zero(cr, 1, currency, amount) == expected, "Rounding error: %s should be zero!" % amount
170 try_zero(0.01, False)
171 try_zero(-0.01, False)
172 try_zero(0.001, True)
173 try_zero(-0.001, True)
174 try_zero(0.0046, True)
175 try_zero(-0.0046, True)
176 try_zero(2.68-2.675, False) # 2.68 - 2.675 = 0.005 -> rounds to 0.01
177 try_zero(2.68-2.676, True) # 2.68 - 2.675 = 0.004 -> rounds to 0.0
178 try_zero(2.676-2.68, True) # 2.675 - 2.68 = -0.004 -> rounds to -0.0
179 try_zero(2.675-2.68, False) # 2.675 - 2.68 = -0.005 -> rounds to -0.01
180
181 def try_compare(amount1, amount2, expected, self=self, cr=cr, currency=currency):
182 assert self.compare_amounts(cr, 1, currency, amount1, amount2) == expected, \
183 "Rounding error, compare_amounts(%s,%s) should be %s" % (amount1, amount2, expected)
184 try_compare(0.001, 0.001, 0)
185 try_compare(-0.001, -0.001, 0)
186 try_compare(0.001, 0.002, 0)
187 try_compare(-0.001, -0.002, 0)
188 try_compare(2.675, 2.68, 0)
189 try_compare(2.676, 2.68, 0)
190 try_compare(-2.676, -2.68, 0)
191 try_compare(2.674, 2.68, -1)
192 try_compare(-2.674, -2.68, 1)
193 try_compare(3, 2.68, 1)
194 try_compare(-3, -2.68, -1)
195 try_compare(0.01, 0, 1)
196 try_compare(-0.01, 0, -1)
197
198-
199 "Float precision tests: verify that float rounding methods are working correctly via tools"
200-
201 !python {model: res.currency}: |
202 from tools import float_compare, float_is_zero, float_round, float_repr
203 def try_round(amount, expected, precision_digits=3, float_round=float_round, float_repr=float_repr):
204 result = float_repr(float_round(amount, precision_digits=precision_digits),
205 precision_digits=precision_digits)
206 assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
207 try_round(2.6745, '2.675')
208 try_round(-2.6745, '-2.675')
209 try_round(2.6744, '2.674')
210 try_round(-2.6744, '-2.674')
211 try_round(0.0004, '0.000')
212 try_round(-0.0004, '-0.000')
213 try_round(357.4555, '357.456')
214 try_round(-357.4555, '-357.456')
215 try_round(457.4554, '457.455')
216 try_round(-457.4554, '-457.455')
217
218 # Extended float range test, inspired by Cloves Almeida's test on bug #882036.
219 fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555]
220 expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
221 precisions = [2, 2, 2, 2, 2, 2, 3, 4]
222 # Note: max precision for double floats is 53 bits of precision or
223 # 17 significant decimal digits
224 for magnitude in range(7):
225 for i in xrange(len(fractions)):
226 frac, exp, prec = fractions[i], expecteds[i], precisions[i]
227 for sign in [-1,1]:
228 for x in xrange(0,10000,97):
229 n = x * 10**magnitude
230 f = sign * (n + frac)
231 f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp
232 try_round(f, f_exp, precision_digits=prec)
233
234
235 def try_zero(amount, expected, float_is_zero=float_is_zero):
236 assert float_is_zero(amount, precision_digits=3) == expected, "Rounding error: %s should be zero!" % amount
237 try_zero(0.0002, True)
238 try_zero(-0.0002, True)
239 try_zero(0.00034, True)
240 try_zero(0.0005, False)
241 try_zero(-0.0005, False)
242 try_zero(0.0008, False)
243 try_zero(-0.0008, False)
244
245 def try_compare(amount1, amount2, expected, float_compare=float_compare):
246 assert float_compare(amount1, amount2, precision_digits=3) == expected, \
247 "Rounding error, compare_amounts(%s,%s) should be %s" % (amount1, amount2, expected)
248 try_compare(0.0003, 0.0004, 0)
249 try_compare(-0.0003, -0.0004, 0)
250 try_compare(0.0002, 0.0005, -1)
251 try_compare(-0.0002, -0.0005, 1)
252 try_compare(0.0009, 0.0004, 1)
253 try_compare(-0.0009, -0.0004, -1)
254 try_compare(557.4555, 557.4556, 0)
255 try_compare(-557.4555, -557.4556, 0)
256 try_compare(657.4444, 657.445, -1)
257 try_compare(-657.4444, -657.445, 1)
258
259 # Rounding to unusual rounding units (e.g. coin values)
260 def try_round(amount, expected, precision_rounding=None, float_round=float_round, float_repr=float_repr):
261 result = float_repr(float_round(amount, precision_rounding=precision_rounding),
262 precision_digits=2)
263 assert result == expected, 'Rounding error: got %s, expected %s' % (result, expected)
264 try_round(-457.4554, '-457.45', precision_rounding=0.05)
265 try_round(457.444, '457.50', precision_rounding=0.5)
266 try_round(457.3, '455.00', precision_rounding=5)
267 try_round(457.5, '460.00', precision_rounding=5)
268 try_round(457.1, '456.00', precision_rounding=3)
269
270-
271 "Float precision tests: check that proper rounding is performed for float persistence"
272-
273 !python {model: res.currency}: |
274 currency = self.browse(cr, uid, ref('base.EUR'))
275 res_currency_rate = self.pool.get('res.currency.rate')
276 from tools import float_compare, float_is_zero, float_round, float_repr
277 def try_roundtrip(value, expected, self=self, cr=cr, currency=currency,
278 res_currency_rate=res_currency_rate):
279 rate_id = res_currency_rate.create(cr, 1, {'name':'2000-01-01',
280 'rate': value,
281 'currency_id': currency.id})
282 rate = res_currency_rate.read(cr, 1, rate_id, ['rate'])['rate']
283 assert rate == expected, 'Roundtrip error: got %s back from db, expected %s' % (rate, expected)
284 # res.currency.rate uses 6 digits of precision by default
285 try_roundtrip(2.6748955, 2.674896)
286 try_roundtrip(-2.6748955, -2.674896)
287 try_roundtrip(10000.999999, 10000.999999)
288 try_roundtrip(-10000.999999, -10000.999999)
289
290-
291 "Float precision tests: verify that invalid parameters are forbidden"
292-
293 !python {model: res.currency}: |
294 from tools import float_compare, float_is_zero, float_round
295 try:
296 float_is_zero(0.01, precision_digits=3, precision_rounding=0.01)
297 except AssertionError:
298 pass
299 try:
300 float_compare(0.01, 0.02, precision_digits=3, precision_rounding=0.01)
301 except AssertionError:
302 pass
303 try:
304 float_round(0.01, precision_digits=3, precision_rounding=0.01)
305 except AssertionError:
306 pass
147307
=== modified file 'openerp/osv/fields.py'
--- openerp/osv/fields.py 2011-11-28 12:45:35 +0000
+++ openerp/osv/fields.py 2011-12-21 01:14:24 +0000
@@ -45,6 +45,7 @@
45import openerp.netsvc as netsvc45import openerp.netsvc as netsvc
46import openerp.tools as tools46import openerp.tools as tools
47from openerp.tools.translate import _47from openerp.tools.translate import _
48from openerp.tools import float_round, float_repr
4849
49def _symbol_set(symb):50def _symbol_set(symb):
50 if symb == None or symb == False:51 if symb == None or symb == False:
@@ -229,17 +230,20 @@
229 def __init__(self, string='unknown', digits=None, digits_compute=None, required=False, **args):230 def __init__(self, string='unknown', digits=None, digits_compute=None, required=False, **args):
230 _column.__init__(self, string=string, required=required, **args)231 _column.__init__(self, string=string, required=required, **args)
231 self.digits = digits232 self.digits = digits
233 # synopsis: digits_compute(cr) -> (precision, scale)
232 self.digits_compute = digits_compute234 self.digits_compute = digits_compute
233 if required:235 if required:
234 warnings.warn("Making a float field `required` has no effect, as NULL values are "236 warnings.warn("Making a float field `required` has no effect, as NULL values are "
235 "automatically turned into 0.0", PendingDeprecationWarning, stacklevel=2)237 "automatically turned into 0.0", PendingDeprecationWarning, stacklevel=2)
236238
237
238 def digits_change(self, cr):239 def digits_change(self, cr):
239 if self.digits_compute:240 if self.digits_compute:
240 t = self.digits_compute(cr)241 self.digits = self.digits_compute(cr)
241 self._symbol_set=('%s', lambda x: ('%.'+str(t[1])+'f') % (__builtin__.float(x or 0.0),))242 if self.digits:
242 self.digits = t243 precision, scale = self.digits
244 self._symbol_set = ('%s', lambda x: float_repr(float_round(__builtin__.float(x or 0.0),
245 precision_digits=scale),
246 precision_digits=scale))
243247
244class date(_column):248class date(_column):
245 _type = 'date'249 _type = 'date'
@@ -990,11 +994,14 @@
990 self._symbol_set = integer._symbol_set994 self._symbol_set = integer._symbol_set
991995
992 def digits_change(self, cr):996 def digits_change(self, cr):
993 if self.digits_compute:997 if self._type == 'float':
994 t = self.digits_compute(cr)998 if self.digits_compute:
995 self._symbol_set=('%s', lambda x: ('%.'+str(t[1])+'f') % (__builtin__.float(x or 0.0),))999 self.digits = self.digits_compute(cr)
996 self.digits = t1000 if self.digits:
9971001 precision, scale = self.digits
1002 self._symbol_set = ('%s', lambda x: float_repr(float_round(__builtin__.float(x or 0.0),
1003 precision_digits=scale),
1004 precision_digits=scale))
9981005
999 def search(self, cr, uid, obj, name, args, context=None):1006 def search(self, cr, uid, obj, name, args, context=None):
1000 if not self._fnct_search:1007 if not self._fnct_search:
10011008
=== modified file 'openerp/tools/__init__.py'
--- openerp/tools/__init__.py 2011-06-23 09:03:57 +0000
+++ openerp/tools/__init__.py 2011-12-21 01:14:24 +0000
@@ -31,6 +31,7 @@
31from pdf_utils import *31from pdf_utils import *
32from yaml_import import *32from yaml_import import *
33from sql import *33from sql import *
34from float_utils import *
3435
35#.apidoc title: Tools36#.apidoc title: Tools
3637
3738
=== added file 'openerp/tools/float_utils.py'
--- openerp/tools/float_utils.py 1970-01-01 00:00:00 +0000
+++ openerp/tools/float_utils.py 2011-12-21 01:14:24 +0000
@@ -0,0 +1,186 @@
1# -*- coding: utf-8 -*-
2##############################################################################
3#
4# OpenERP, Open Source Business Applications
5# Copyright (c) 2011 OpenERP S.A. <http://openerp.com>
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Affero General Public License as
9# published by the Free Software Foundation, either version 3 of the
10# License, or (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Affero General Public License for more details.
16#
17# You should have received a copy of the GNU Affero General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20##############################################################################
21
22import math
23
24def _float_check_precision(precision_digits=None, precision_rounding=None):
25 assert (precision_digits is not None or precision_rounding is not None) and \
26 not (precision_digits and precision_rounding),\
27 "exactly one of precision_digits and precision_rounding must be specified"
28 if precision_digits is not None:
29 return 10 ** -precision_digits
30 return precision_rounding
31
32def float_round(value, precision_digits=None, precision_rounding=None):
33 """Return ``value`` rounded to ``precision_digits``
34 decimal digits, minimizing IEEE-754 floating point representation
35 errors, and applying HALF-UP (away from zero) tie-breaking rule.
36 Precision must be given by ``precision_digits`` or ``precision_rounding``,
37 not both!
38
39 :param float value: the value to round
40 :param int precision_digits: number of fractional digits to round to.
41 :param float precision_rounding: decimal number representing the minimum
42 non-zero value at the desired precision (for example, 0.01 for a
43 2-digit precision).
44 :return: rounded float
45 """
46 rounding_factor = _float_check_precision(precision_digits=precision_digits,
47 precision_rounding=precision_rounding)
48 if rounding_factor == 0 or value == 0: return 0.0
49
50 # NORMALIZE - ROUND - DENORMALIZE
51 # In order to easily support rounding to arbitrary 'steps' (e.g. coin values),
52 # we normalize the value before rounding it as an integer, and de-normalize
53 # after rounding: e.g. float_round(1.3, precision_rounding=.5) == 1.5
54
55 # TIE-BREAKING: HALF-UP
56 # We want to apply HALF-UP tie-breaking rules, i.e. 0.5 rounds away from 0.
57 # Due to IEE754 float/double representation limits, the approximation of the
58 # real value may be slightly below the tie limit, resulting in an error of
59 # 1 unit in the last place (ulp) after rounding.
60 # For example 2.675 == 2.6749999999999998.
61 # To correct this, we add a very small epsilon value, scaled to the
62 # the order of magnitude of the value, to tip the tie-break in the right
63 # direction.
64 # Credit: discussion with OpenERP community members on bug 882036
65
66 normalized_value = value / rounding_factor # normalize
67 epsilon_magnitude = math.log(abs(normalized_value), 2)
68 epsilon = 2**(epsilon_magnitude-53)
69 normalized_value += cmp(normalized_value,0) * epsilon
70 rounded_value = round(normalized_value) # round to integer
71 result = rounded_value * rounding_factor # de-normalize
72 return result
73
74def float_is_zero(value, precision_digits=None, precision_rounding=None):
75 """Returns true if ``value`` is small enough to be treated as
76 zero at the given precision (smaller than the corresponding *epsilon*).
77 The precision (``10**-precision_digits`` or ``precision_rounding``)
78 is used as the zero *epsilon*: values less than that are considered
79 to be zero.
80 Precision must be given by ``precision_digits`` or ``precision_rounding``,
81 not both!
82
83 Warning: ``float_is_zero(value1-value2)`` is not equivalent to
84 ``float_compare(value1,value2) == 0``, as the former will round after
85 computing the difference, while the latter will round before, giving
86 different results for e.g. 0.006 and 0.002 at 2 digits precision.
87
88 :param int precision_digits: number of fractional digits to round to.
89 :param float precision_rounding: decimal number representing the minimum
90 non-zero value at the desired precision (for example, 0.01 for a
91 2-digit precision).
92 :param float value: value to compare with the precision's zero
93 :return: True if ``value`` is considered zero
94 """
95 epsilon = _float_check_precision(precision_digits=precision_digits,
96 precision_rounding=precision_rounding)
97 return abs(float_round(value, precision_rounding=epsilon)) < epsilon
98
99def float_compare(value1, value2, precision_digits=None, precision_rounding=None):
100 """Compare ``value1`` and ``value2`` after rounding them according to the
101 given precision. A value is considered lower/greater than another value
102 if their rounded value is different. This is not the same as having a
103 non-zero difference!
104 Precision must be given by ``precision_digits`` or ``precision_rounding``,
105 not both!
106
107 Example: 1.432 and 1.431 are equal at 2 digits precision,
108 so this method would return 0
109 However 0.006 and 0.002 are considered different (this method returns 1)
110 because they respectively round to 0.01 and 0.0, even though
111 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision.
112
113 Warning: ``float_is_zero(value1-value2)`` is not equivalent to
114 ``float_compare(value1,value2) == 0``, as the former will round after
115 computing the difference, while the latter will round before, giving
116 different results for e.g. 0.006 and 0.002 at 2 digits precision.
117
118 :param int precision_digits: number of fractional digits to round to.
119 :param float precision_rounding: decimal number representing the minimum
120 non-zero value at the desired precision (for example, 0.01 for a
121 2-digit precision).
122 :param float value1: first value to compare
123 :param float value2: second value to compare
124 :return: (resp.) -1, 0 or 1, if ``value1`` is (resp.) lower than,
125 equal to, or greater than ``value2``, at the given precision.
126 """
127 rounding_factor = _float_check_precision(precision_digits=precision_digits,
128 precision_rounding=precision_rounding)
129 value1 = float_round(value1, precision_rounding=rounding_factor)
130 value2 = float_round(value2, precision_rounding=rounding_factor)
131 delta = value1 - value2
132 if float_is_zero(delta, precision_rounding=rounding_factor): return 0
133 return -1 if delta < 0.0 else 1
134
135def float_repr(value, precision_digits):
136 """Returns a string representation of a float with the
137 the given number of fractional digits. This should not be
138 used to perform a rounding operation (this is done via
139 :meth:`~.float_round`), but only to produce a suitable
140 string representation for a float.
141
142 :param int precision_digits: number of fractional digits to
143 include in the output
144 """
145 # Can't use str() here because it seems to have an intrisic
146 # rounding to 12 significant digits, which causes a loss of
147 # precision. e.g. str(123456789.1234) == str(123456789.123)!!
148 return ("%%.%sf" % precision_digits) % value
149
150
151if __name__ == "__main__":
152
153 import time
154 start = time.time()
155 count = 0
156 errors = 0
157
158 def try_round(amount, expected, precision_digits=3):
159 global count, errors; count += 1
160 result = float_repr(float_round(amount, precision_digits=precision_digits),
161 precision_digits=precision_digits)
162 if result != expected:
163 errors += 1
164 print '###!!! Rounding error: got %s , expected %s' % (result, expected)
165
166 # Extended float range test, inspired by Cloves Almeida's test on bug #882036.
167 fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555]
168 expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
169 precisions = [2, 2, 2, 2, 2, 2, 3, 4]
170 for magnitude in range(7):
171 for i in xrange(len(fractions)):
172 frac, exp, prec = fractions[i], expecteds[i], precisions[i]
173 for sign in [-1,1]:
174 for x in xrange(0,10000,97):
175 n = x * 10**magnitude
176 f = sign * (n + frac)
177 f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp
178 try_round(f, f_exp, precision_digits=prec)
179
180 stop = time.time()
181
182 # Micro-bench results:
183 # 47130 round calls in 0.422306060791 secs, with Python 2.6.7 on Core i3 x64
184 # with decimal:
185 # 47130 round calls in 6.612248100021 secs, with Python 2.6.7 on Core i3 x64
186 print count, " round calls, ", errors, "errors, done in ", (stop-start), 'secs'
0187
=== modified file 'openerp/tools/misc.py'
--- openerp/tools/misc.py 2011-09-22 09:54:43 +0000
+++ openerp/tools/misc.py 2011-12-21 01:14:24 +0000
@@ -1200,4 +1200,4 @@
1200 def __missing__(self, key):1200 def __missing__(self, key):
1201 return unquote(key)1201 return unquote(key)
12021202
1203# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:1203# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1204\ No newline at end of file1204\ No newline at end of file