-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfloat_utils.py
More file actions
410 lines (344 loc) · 16.7 KB
/
Copy pathfloat_utils.py
File metadata and controls
410 lines (344 loc) · 16.7 KB
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from typing import Literal, overload
import builtins
import math
RoundingMethod = Literal['UP', 'DOWN', 'HALF-UP', 'HALF-DOWN', 'HALF-EVEN']
__all__ = [
"float_compare",
"float_is_zero",
"float_repr",
"float_round",
"float_split",
"float_split_str",
]
def round(f: float) -> float:
# P3's builtin round differs from P2 in the following manner:
# * it rounds half to even rather than up (away from 0)
# * round(-0.) loses the sign (it returns -0 rather than 0)
# * round(x) returns an int rather than a float
#
# this compatibility shim implements Python 2's round in terms of
# Python 3's so that important rounding error under P3 can be
# trivially fixed, assuming the P2 behaviour to be debugged and
# correct.
roundf = builtins.round(f)
if builtins.round(f + 1) - roundf != 1:
return f + math.copysign(0.5, f)
# copysign ensures round(-0.) -> -0 *and* result is a float
return math.copysign(roundf, f)
def _float_check_precision(
precision_digits: int | None = None,
precision_rounding: float | None = None,
) -> float:
if precision_rounding is not None and precision_digits is None:
assert precision_rounding > 0,\
f"precision_rounding must be positive, got {precision_rounding}"
elif precision_digits is not None and precision_rounding is None:
# TODO: `int`s will also get the `is_integer` method starting from python 3.12
assert float(precision_digits).is_integer() and precision_digits >= 0,\
f"precision_digits must be a non-negative integer, got {precision_digits}"
precision_rounding = 10 ** -precision_digits
else:
msg = "exactly one of precision_digits and precision_rounding must be specified"
raise AssertionError(msg)
return precision_rounding
@overload
def float_round(
value: float,
precision_digits: int,
rounding_method: RoundingMethod = ...,
) -> float: ...
@overload
def float_round(
value: float,
precision_rounding: float,
rounding_method: RoundingMethod = ...,
) -> float: ...
def float_round(
value: float,
precision_digits: int | None = None,
precision_rounding: float | None = None,
rounding_method: RoundingMethod = 'HALF-UP',
) -> float:
"""Return ``value`` rounded to ``precision_digits`` decimal digits,
minimizing IEEE-754 floating point representation errors, and applying
the tie-breaking rule selected with ``rounding_method``, by default
HALF-UP (away from zero).
Precision must be given by ``precision_digits`` or ``precision_rounding``,
not both!
:param value: the value to round
:param precision_digits: number of fractional digits to round to.
:param precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
:param rounding_method: the rounding method used:
- 'HALF-UP' will round to the closest number with ties going away from zero.
- 'HALF-DOWN' will round to the closest number with ties going towards zero.
- 'HALF-EVEN' will round to the closest number with ties going to the closest
even number.
- 'UP' will always round away from 0.
- 'DOWN' will always round towards 0.
:return: rounded float
"""
rounding_factor = _float_check_precision(precision_digits=precision_digits,
precision_rounding=precision_rounding)
if rounding_factor == 0 or value == 0:
return 0.0
# 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
def normalize(val):
return val / rounding_factor
def denormalize(val):
return val * rounding_factor
# inverting small rounding factors reduces rounding errors
if rounding_factor < 1:
rounding_factor = float_invert(rounding_factor)
normalize, denormalize = denormalize, normalize
normalized_value = normalize(value)
# Due to IEEE-754 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
epsilon_magnitude = math.log2(abs(normalized_value))
# `2**(epsilon_magnitude - 52)` would be the minimal size, but we increase it to be
# more tolerant of inaccuracies accumulated after multiple floating point operations
epsilon = 2**(epsilon_magnitude - 50)
match rounding_method:
case 'HALF-UP': # 0.5 rounds away from 0
result = round(normalized_value + math.copysign(epsilon, normalized_value))
case 'HALF-EVEN': # 0.5 rounds towards closest even number
integral = math.floor(normalized_value)
remainder = abs(normalized_value - integral)
is_half = abs(0.5 - remainder) < epsilon
# if is_half & integral is odd, add odd bit to make it even
result = integral + (integral & 1) if is_half else round(normalized_value)
case 'HALF-DOWN': # 0.5 rounds towards 0
result = round(normalized_value - math.copysign(epsilon, normalized_value))
case 'UP': # round to number furthest from zero
result = math.trunc(normalized_value + math.copysign(1 - epsilon, normalized_value))
case 'DOWN': # round to number closest to zero
result = math.trunc(normalized_value + math.copysign(epsilon, normalized_value))
case _:
msg = f"unknown rounding method: {rounding_method}"
raise ValueError(msg)
return denormalize(result)
@overload
def float_is_zero(
value: float,
precision_digits: int,
) -> bool: ...
@overload
def float_is_zero(
value: float,
precision_rounding: float,
) -> bool: ...
def float_is_zero(
value: float,
precision_digits: int | None = None,
precision_rounding: float | None = None,
) -> bool:
"""Returns true if ``value`` is small enough to be treated as
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!
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 precision_digits: number of fractional digits to round to.
:param precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
:param 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)
return value == 0.0 or abs(float_round(value, precision_rounding=epsilon)) < epsilon
@overload
def float_compare(
value1: float,
value2: float,
precision_digits: int,
) -> Literal[-1, 0, 1]: ...
@overload
def float_compare(
value1: float,
value2: float,
precision_rounding: float,
) -> Literal[-1, 0, 1]: ...
def float_compare(
value1: float,
value2: float,
precision_digits: int | None = None,
precision_rounding: float | None = None,
) -> Literal[-1, 0, 1]:
"""Compare ``value1`` and ``value2`` after rounding them according to the
given precision. A value is considered lower/greater than another value
if their rounded value is different. This is not the same as having a
non-zero difference!
Precision must be given by ``precision_digits`` or ``precision_rounding``,
not both!
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 (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 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 value1: first value to compare
:param value2: second value to compare
:param precision_digits: number of fractional digits to round to.
:param precision_rounding: decimal number representing the minimum
non-zero value at the desired precision (for example, 0.01 for a
2-digit precision).
:return: (resp.) -1, 0 or 1, if ``value1`` is (resp.) lower than,
equal to, or greater than ``value2``, at the given precision.
"""
rounding_factor = _float_check_precision(precision_digits=precision_digits,
precision_rounding=precision_rounding)
# equal numbers round equally, so we can skip that step
# doing this after _float_check_precision to validate parameters first
if value1 == value2:
return 0
value1 = float_round(value1, precision_rounding=rounding_factor)
value2 = float_round(value2, precision_rounding=rounding_factor)
delta = value1 - value2
if float_is_zero(delta, precision_rounding=rounding_factor):
return 0
return -1 if delta < 0.0 else 1
def float_repr(value: float, precision_digits: int) -> str:
"""Returns a string representation of a float with the
given number of fractional digits. This should not be
used to perform a rounding operation (this is done via
:func:`~.float_round`), but only to produce a suitable
string representation for a float.
:param value: the value to represent
:param precision_digits: number of fractional digits to include in the output
:return: the string representation of the value
"""
# Can't use str() here because it seems to have an intrinsic
# rounding to 12 significant digits, which causes a loss of
# precision. e.g. str(123456789.1234) == str(123456789.123)!!
if float_is_zero(value, precision_digits=precision_digits):
value = 0.0
return "%.*f" % (precision_digits, value)
def float_split_str(value: float, precision_digits: int) -> tuple[str, str]:
"""Splits the given float 'value' in its unitary and decimal parts,
returning each of them as a string, rounding the value using
the provided ``precision_digits`` argument.
The length of the string returned for decimal places will always
be equal to ``precision_digits``, adding zeros at the end if needed.
In case ``precision_digits`` is zero, an empty string is returned for
the decimal places.
Examples:
1.432 with precision 2 => ('1', '43')
1.49 with precision 1 => ('1', '5')
1.1 with precision 3 => ('1', '100')
1.12 with precision 0 => ('1', '')
:param value: value to split.
:param precision_digits: number of fractional digits to round to.
:return: returns the tuple(<unitary part>, <decimal part>) of the given value
"""
value = float_round(value, precision_digits=precision_digits)
value_repr = float_repr(value, precision_digits)
return tuple(value_repr.split('.')) if precision_digits else (value_repr, '')
def float_split(value: float, precision_digits: int) -> tuple[int, int]:
""" same as float_split_str() except that it returns the unitary and decimal
parts as integers instead of strings. In case ``precision_digits`` is zero,
0 is always returned as decimal part.
"""
units, cents = float_split_str(value, precision_digits)
if not cents:
return int(units), 0
return int(units), int(cents)
def json_float_round(
value: float,
precision_digits: int,
rounding_method: RoundingMethod = 'HALF-UP',
) -> float:
"""Not suitable for float calculations! Similar to float_repr except that it
returns a float suitable for json dump
This may be necessary to produce "exact" representations of rounded float
values during serialization, such as what is done by `json.dumps()`.
Unfortunately `json.dumps` does not allow any form of custom float representation,
nor any custom types, everything is serialized from the basic JSON types.
:param precision_digits: number of fractional digits to round to.
:param rounding_method: the rounding method used: 'HALF-UP', 'UP' or 'DOWN',
the first one rounding up to the closest number with the rule that
number>=0.5 is rounded up to 1, the second always rounding up and the
latest one always rounding down.
:return: a rounded float value that must not be used for calculations, but
is ready to be serialized in JSON with minimal chances of
representation errors.
"""
rounded_value = float_round(value, precision_digits=precision_digits, rounding_method=rounding_method)
rounded_repr = float_repr(rounded_value, precision_digits=precision_digits)
# As of Python 3.1, rounded_repr should be the shortest representation for our
# rounded float, so we create a new float whose repr is expected
# to be the same value, or a value that is semantically identical
# and will be used in the json serialization.
# e.g. if rounded_repr is '3.1750', the new float repr could be 3.175
# but not 3.174999999999322452.
# Cfr. bpo-1580: https://bugs.python.org/issue1580
return float(rounded_repr)
_INVERTDICT = {
1e-1: 1e+1, 1e-2: 1e+2, 1e-3: 1e+3, 1e-4: 1e+4, 1e-5: 1e+5,
1e-6: 1e+6, 1e-7: 1e+7, 1e-8: 1e+8, 1e-9: 1e+9, 1e-10: 1e+10,
2e-1: 5e+0, 2e-2: 5e+1, 2e-3: 5e+2, 2e-4: 5e+3, 2e-5: 5e+4,
2e-6: 5e+5, 2e-7: 5e+6, 2e-8: 5e+7, 2e-9: 5e+8, 2e-10: 5e+9,
5e-1: 2e+0, 5e-2: 2e+1, 5e-3: 2e+2, 5e-4: 2e+3, 5e-5: 2e+4,
5e-6: 2e+5, 5e-7: 2e+6, 5e-8: 2e+7, 5e-9: 2e+8, 5e-10: 2e+9,
}
def float_invert(value: float) -> float:
"""Inverts a floating point number with increased accuracy.
:param value: value to invert.
:return: inverted float.
"""
result = _INVERTDICT.get(value)
if result is None:
coefficient, exponent = f'{value:.15e}'.split('e')
# invert exponent by changing sign, and coefficient by dividing by its square
result = float(f'{coefficient}e{-int(exponent)}') / float(coefficient)**2
return result
if __name__ == "__main__":
import time
start = time.time()
count = 0
def try_round(amount, expected, precision_digits=3):
result = float_repr(float_round(amount, precision_digits=precision_digits),
precision_digits=precision_digits)
if result != expected:
print('###!!! Rounding error: got %s , expected %s' % (result, expected))
return complex(1, 1)
return 1
# Extended float range test, inspired by Cloves Almeida's test on bug #882036.
fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555]
expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556']
precisions = [2, 2, 2, 2, 2, 2, 3, 4]
for magnitude in range(7):
for frac, exp, prec in zip(fractions, expecteds, precisions):
for sign in [-1, 1]:
for x in range(0, 10000, 97):
n = x * 10**magnitude
f = sign * (n + frac)
f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp
count += try_round(f, f_exp, precision_digits=prec)
stop = time.time()
count, errors = int(count.real), int(count.imag)
# Micro-bench results:
# 47130 round calls in 0.422306060791 secs, with Python 2.6.7 on Core i3 x64
# with decimal:
# 47130 round calls in 6.612248100021 secs, with Python 2.6.7 on Core i3 x64
print(count, " round calls, ", errors, "errors, done in ", (stop-start), 'secs')