Coverage for adhoc-cicd-odoo-odoo / odoo / tools / float_utils.py: 55%

112 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2 

3from typing import Literal, overload 

4 

5import builtins 

6import math 

7 

8RoundingMethod = Literal['UP', 'DOWN', 'HALF-UP', 'HALF-DOWN', 'HALF-EVEN'] 

9 

10__all__ = [ 

11 "float_compare", 

12 "float_is_zero", 

13 "float_repr", 

14 "float_round", 

15 "float_split", 

16 "float_split_str", 

17] 

18 

19 

20def round(f: float) -> float: 

21 # P3's builtin round differs from P2 in the following manner: 

22 # * it rounds half to even rather than up (away from 0) 

23 # * round(-0.) loses the sign (it returns -0 rather than 0) 

24 # * round(x) returns an int rather than a float 

25 # 

26 # this compatibility shim implements Python 2's round in terms of 

27 # Python 3's so that important rounding error under P3 can be 

28 # trivially fixed, assuming the P2 behaviour to be debugged and 

29 # correct. 

30 roundf = builtins.round(f) 

31 if builtins.round(f + 1) - roundf != 1: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true

32 return f + math.copysign(0.5, f) 

33 # copysign ensures round(-0.) -> -0 *and* result is a float 

34 return math.copysign(roundf, f) 

35 

36 

37def _float_check_precision( 

38 precision_digits: int | None = None, 

39 precision_rounding: float | None = None, 

40) -> float: 

41 if precision_rounding is not None and precision_digits is None: 

42 assert precision_rounding > 0,\ 

43 f"precision_rounding must be positive, got {precision_rounding}" 

44 elif precision_digits is not None and precision_rounding is None: 44 ↛ 50line 44 didn't jump to line 50 because the condition on line 44 was always true

45 # TODO: `int`s will also get the `is_integer` method starting from python 3.12 

46 assert float(precision_digits).is_integer() and precision_digits >= 0,\ 

47 f"precision_digits must be a non-negative integer, got {precision_digits}" 

48 precision_rounding = 10 ** -precision_digits 

49 else: 

50 msg = "exactly one of precision_digits and precision_rounding must be specified" 

51 raise AssertionError(msg) 

52 return precision_rounding 

53 

54 

55@overload 

56def float_round( 

57 value: float, 

58 precision_digits: int, 

59 rounding_method: RoundingMethod = ..., 

60) -> float: ... 

61 

62 

63@overload 

64def float_round( 

65 value: float, 

66 precision_rounding: float, 

67 rounding_method: RoundingMethod = ..., 

68) -> float: ... 

69 

70 

71def float_round( 

72 value: float, 

73 precision_digits: int | None = None, 

74 precision_rounding: float | None = None, 

75 rounding_method: RoundingMethod = 'HALF-UP', 

76) -> float: 

77 """Return ``value`` rounded to ``precision_digits`` decimal digits, 

78 minimizing IEEE-754 floating point representation errors, and applying 

79 the tie-breaking rule selected with ``rounding_method``, by default 

80 HALF-UP (away from zero). 

81 Precision must be given by ``precision_digits`` or ``precision_rounding``, 

82 not both! 

83 

84 :param value: the value to round 

85 :param precision_digits: number of fractional digits to round to. 

86 :param precision_rounding: decimal number representing the minimum 

87 non-zero value at the desired precision (for example, 0.01 for a 

88 2-digit precision). 

89 :param rounding_method: the rounding method used: 

90 - 'HALF-UP' will round to the closest number with ties going away from zero. 

91 - 'HALF-DOWN' will round to the closest number with ties going towards zero. 

92 - 'HALF-EVEN' will round to the closest number with ties going to the closest 

93 even number. 

94 - 'UP' will always round away from 0. 

95 - 'DOWN' will always round towards 0. 

96 :return: rounded float 

97 """ 

98 rounding_factor = _float_check_precision(precision_digits=precision_digits, 

99 precision_rounding=precision_rounding) 

100 if rounding_factor == 0 or value == 0: 

101 return 0.0 

102 

103 # NORMALIZE - ROUND - DENORMALIZE 

104 # In order to easily support rounding to arbitrary 'steps' (e.g. coin values), 

105 # we normalize the value before rounding it as an integer, and de-normalize 

106 # after rounding: e.g. float_round(1.3, precision_rounding=.5) == 1.5 

107 def normalize(val): 

108 return val / rounding_factor 

109 

110 def denormalize(val): 

111 return val * rounding_factor 

112 

113 # inverting small rounding factors reduces rounding errors 

114 if rounding_factor < 1: 

115 rounding_factor = float_invert(rounding_factor) 

116 normalize, denormalize = denormalize, normalize 

117 

118 normalized_value = normalize(value) 

119 

120 # Due to IEEE-754 float/double representation limits, the approximation of the 

121 # real value may be slightly below the tie limit, resulting in an error of 

122 # 1 unit in the last place (ulp) after rounding. 

123 # For example 2.675 == 2.6749999999999998. 

124 # To correct this, we add a very small epsilon value, scaled to the 

125 # the order of magnitude of the value, to tip the tie-break in the right 

126 # direction. 

127 # Credit: discussion with OpenERP community members on bug 882036 

128 epsilon_magnitude = math.log2(abs(normalized_value)) 

129 # `2**(epsilon_magnitude - 52)` would be the minimal size, but we increase it to be 

130 # more tolerant of inaccuracies accumulated after multiple floating point operations 

131 epsilon = 2**(epsilon_magnitude - 50) 

132 

133 match rounding_method: 

134 case 'HALF-UP': # 0.5 rounds away from 0 

135 result = round(normalized_value + math.copysign(epsilon, normalized_value)) 

136 case 'HALF-EVEN': # 0.5 rounds towards closest even number 136 ↛ 142line 136 didn't jump to line 142 because the pattern on line 136 always matched

137 integral = math.floor(normalized_value) 

138 remainder = abs(normalized_value - integral) 

139 is_half = abs(0.5 - remainder) < epsilon 

140 # if is_half & integral is odd, add odd bit to make it even 

141 result = integral + (integral & 1) if is_half else round(normalized_value) 

142 case 'HALF-DOWN': # 0.5 rounds towards 0 

143 result = round(normalized_value - math.copysign(epsilon, normalized_value)) 

144 case 'UP': # round to number furthest from zero 

145 result = math.trunc(normalized_value + math.copysign(1 - epsilon, normalized_value)) 

146 case 'DOWN': # round to number closest to zero 

147 result = math.trunc(normalized_value + math.copysign(epsilon, normalized_value)) 

148 case _: 

149 msg = f"unknown rounding method: {rounding_method}" 

150 raise ValueError(msg) 

151 

152 return denormalize(result) 

153 

154 

155@overload 

156def float_is_zero( 

157 value: float, 

158 precision_digits: int, 

159) -> bool: ... 

160 

161 

162@overload 

163def float_is_zero( 

164 value: float, 

165 precision_rounding: float, 

166) -> bool: ... 

167 

168 

169def float_is_zero( 

170 value: float, 

171 precision_digits: int | None = None, 

172 precision_rounding: float | None = None, 

173) -> bool: 

174 """Returns true if ``value`` is small enough to be treated as 

175 zero at the given precision (smaller than the corresponding *epsilon*). 

176 The precision (``10**-precision_digits`` or ``precision_rounding``) 

177 is used as the zero *epsilon*: values less than that are considered 

178 to be zero. 

179 Precision must be given by ``precision_digits`` or ``precision_rounding``, 

180 not both! 

181 

182 Warning: ``float_is_zero(value1-value2)`` is not equivalent to 

183 ``float_compare(value1,value2) == 0``, as the former will round after 

184 computing the difference, while the latter will round before, giving 

185 different results for e.g. 0.006 and 0.002 at 2 digits precision. 

186 

187 :param precision_digits: number of fractional digits to round to. 

188 :param precision_rounding: decimal number representing the minimum 

189 non-zero value at the desired precision (for example, 0.01 for a 

190 2-digit precision). 

191 :param value: value to compare with the precision's zero 

192 :return: True if ``value`` is considered zero 

193 """ 

194 epsilon = _float_check_precision(precision_digits=precision_digits, 

195 precision_rounding=precision_rounding) 

196 return value == 0.0 or abs(float_round(value, precision_rounding=epsilon)) < epsilon 

197 

198 

199@overload 

200def float_compare( 

201 value1: float, 

202 value2: float, 

203 precision_digits: int, 

204) -> Literal[-1, 0, 1]: ... 

205 

206 

207@overload 

208def float_compare( 

209 value1: float, 

210 value2: float, 

211 precision_rounding: float, 

212) -> Literal[-1, 0, 1]: ... 

213 

214 

215def float_compare( 

216 value1: float, 

217 value2: float, 

218 precision_digits: int | None = None, 

219 precision_rounding: float | None = None, 

220) -> Literal[-1, 0, 1]: 

221 """Compare ``value1`` and ``value2`` after rounding them according to the 

222 given precision. A value is considered lower/greater than another value 

223 if their rounded value is different. This is not the same as having a 

224 non-zero difference! 

225 Precision must be given by ``precision_digits`` or ``precision_rounding``, 

226 not both! 

227 

228 Example: 1.432 and 1.431 are equal at 2 digits precision, 

229 so this method would return 0 

230 However 0.006 and 0.002 are considered different (this method returns 1) 

231 because they respectively round to 0.01 and 0.0, even though 

232 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision. 

233 

234 Warning: ``float_is_zero(value1-value2)`` is not equivalent to 

235 ``float_compare(value1,value2) == 0``, as the former will round after 

236 computing the difference, while the latter will round before, giving 

237 different results for e.g. 0.006 and 0.002 at 2 digits precision. 

238 

239 :param value1: first value to compare 

240 :param value2: second value to compare 

241 :param precision_digits: number of fractional digits to round to. 

242 :param precision_rounding: decimal number representing the minimum 

243 non-zero value at the desired precision (for example, 0.01 for a 

244 2-digit precision). 

245 :return: (resp.) -1, 0 or 1, if ``value1`` is (resp.) lower than, 

246 equal to, or greater than ``value2``, at the given precision. 

247 """ 

248 rounding_factor = _float_check_precision(precision_digits=precision_digits, 

249 precision_rounding=precision_rounding) 

250 # equal numbers round equally, so we can skip that step 

251 # doing this after _float_check_precision to validate parameters first 

252 if value1 == value2: 

253 return 0 

254 value1 = float_round(value1, precision_rounding=rounding_factor) 

255 value2 = float_round(value2, precision_rounding=rounding_factor) 

256 delta = value1 - value2 

257 if float_is_zero(delta, precision_rounding=rounding_factor): 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 return 0 

259 return -1 if delta < 0.0 else 1 

260 

261 

262def float_repr(value: float, precision_digits: int) -> str: 

263 """Returns a string representation of a float with the 

264 given number of fractional digits. This should not be 

265 used to perform a rounding operation (this is done via 

266 :func:`~.float_round`), but only to produce a suitable 

267 string representation for a float. 

268 

269 :param value: the value to represent 

270 :param precision_digits: number of fractional digits to include in the output 

271 :return: the string representation of the value 

272 """ 

273 # Can't use str() here because it seems to have an intrinsic 

274 # rounding to 12 significant digits, which causes a loss of 

275 # precision. e.g. str(123456789.1234) == str(123456789.123)!! 

276 if float_is_zero(value, precision_digits=precision_digits): 

277 value = 0.0 

278 return "%.*f" % (precision_digits, value) 

279 

280 

281def float_split_str(value: float, precision_digits: int) -> tuple[str, str]: 

282 """Splits the given float 'value' in its unitary and decimal parts, 

283 returning each of them as a string, rounding the value using 

284 the provided ``precision_digits`` argument. 

285 

286 The length of the string returned for decimal places will always 

287 be equal to ``precision_digits``, adding zeros at the end if needed. 

288 

289 In case ``precision_digits`` is zero, an empty string is returned for 

290 the decimal places. 

291 

292 Examples: 

293 1.432 with precision 2 => ('1', '43') 

294 1.49 with precision 1 => ('1', '5') 

295 1.1 with precision 3 => ('1', '100') 

296 1.12 with precision 0 => ('1', '') 

297 

298 :param value: value to split. 

299 :param precision_digits: number of fractional digits to round to. 

300 :return: returns the tuple(<unitary part>, <decimal part>) of the given value 

301 """ 

302 value = float_round(value, precision_digits=precision_digits) 

303 value_repr = float_repr(value, precision_digits) 

304 return tuple(value_repr.split('.')) if precision_digits else (value_repr, '') 

305 

306 

307def float_split(value: float, precision_digits: int) -> tuple[int, int]: 

308 """ same as float_split_str() except that it returns the unitary and decimal 

309 parts as integers instead of strings. In case ``precision_digits`` is zero, 

310 0 is always returned as decimal part. 

311 """ 

312 units, cents = float_split_str(value, precision_digits) 

313 if not cents: 

314 return int(units), 0 

315 return int(units), int(cents) 

316 

317 

318def json_float_round( 

319 value: float, 

320 precision_digits: int, 

321 rounding_method: RoundingMethod = 'HALF-UP', 

322) -> float: 

323 """Not suitable for float calculations! Similar to float_repr except that it 

324 returns a float suitable for json dump 

325 

326 This may be necessary to produce "exact" representations of rounded float 

327 values during serialization, such as what is done by `json.dumps()`. 

328 Unfortunately `json.dumps` does not allow any form of custom float representation, 

329 nor any custom types, everything is serialized from the basic JSON types. 

330 

331 :param precision_digits: number of fractional digits to round to. 

332 :param rounding_method: the rounding method used: 'HALF-UP', 'UP' or 'DOWN', 

333 the first one rounding up to the closest number with the rule that 

334 number>=0.5 is rounded up to 1, the second always rounding up and the 

335 latest one always rounding down. 

336 :return: a rounded float value that must not be used for calculations, but 

337 is ready to be serialized in JSON with minimal chances of 

338 representation errors. 

339 """ 

340 rounded_value = float_round(value, precision_digits=precision_digits, rounding_method=rounding_method) 

341 rounded_repr = float_repr(rounded_value, precision_digits=precision_digits) 

342 # As of Python 3.1, rounded_repr should be the shortest representation for our 

343 # rounded float, so we create a new float whose repr is expected 

344 # to be the same value, or a value that is semantically identical 

345 # and will be used in the json serialization. 

346 # e.g. if rounded_repr is '3.1750', the new float repr could be 3.175 

347 # but not 3.174999999999322452. 

348 # Cfr. bpo-1580: https://bugs.python.org/issue1580 

349 return float(rounded_repr) 

350 

351 

352_INVERTDICT = { 

353 1e-1: 1e+1, 1e-2: 1e+2, 1e-3: 1e+3, 1e-4: 1e+4, 1e-5: 1e+5, 

354 1e-6: 1e+6, 1e-7: 1e+7, 1e-8: 1e+8, 1e-9: 1e+9, 1e-10: 1e+10, 

355 2e-1: 5e+0, 2e-2: 5e+1, 2e-3: 5e+2, 2e-4: 5e+3, 2e-5: 5e+4, 

356 2e-6: 5e+5, 2e-7: 5e+6, 2e-8: 5e+7, 2e-9: 5e+8, 2e-10: 5e+9, 

357 5e-1: 2e+0, 5e-2: 2e+1, 5e-3: 2e+2, 5e-4: 2e+3, 5e-5: 2e+4, 

358 5e-6: 2e+5, 5e-7: 2e+6, 5e-8: 2e+7, 5e-9: 2e+8, 5e-10: 2e+9, 

359} 

360 

361 

362def float_invert(value: float) -> float: 

363 """Inverts a floating point number with increased accuracy. 

364 

365 :param value: value to invert. 

366 :return: inverted float. 

367 """ 

368 result = _INVERTDICT.get(value) 

369 if result is None: 

370 coefficient, exponent = f'{value:.15e}'.split('e') 

371 # invert exponent by changing sign, and coefficient by dividing by its square 

372 result = float(f'{coefficient}e{-int(exponent)}') / float(coefficient)**2 

373 return result 

374 

375 

376if __name__ == "__main__": 376 ↛ 378line 376 didn't jump to line 378 because the condition on line 376 was never true

377 

378 import time 

379 start = time.time() 

380 count = 0 

381 

382 def try_round(amount, expected, precision_digits=3): 

383 result = float_repr(float_round(amount, precision_digits=precision_digits), 

384 precision_digits=precision_digits) 

385 if result != expected: 

386 print('###!!! Rounding error: got %s , expected %s' % (result, expected)) 

387 return complex(1, 1) 

388 return 1 

389 

390 # Extended float range test, inspired by Cloves Almeida's test on bug #882036. 

391 fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555] 

392 expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556'] 

393 precisions = [2, 2, 2, 2, 2, 2, 3, 4] 

394 for magnitude in range(7): 

395 for frac, exp, prec in zip(fractions, expecteds, precisions): 

396 for sign in [-1, 1]: 

397 for x in range(0, 10000, 97): 

398 n = x * 10**magnitude 

399 f = sign * (n + frac) 

400 f_exp = ('-' if f != 0 and sign == -1 else '') + str(n) + exp 

401 count += try_round(f, f_exp, precision_digits=prec) 

402 

403 stop = time.time() 

404 count, errors = int(count.real), int(count.imag) 

405 

406 # Micro-bench results: 

407 # 47130 round calls in 0.422306060791 secs, with Python 2.6.7 on Core i3 x64 

408 # with decimal: 

409 # 47130 round calls in 6.612248100021 secs, with Python 2.6.7 on Core i3 x64 

410 print(count, " round calls, ", errors, "errors, done in ", (stop-start), 'secs')