From f85760fa8fb525092560985acb779909769ba9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Mattiello?= Date: Mon, 13 May 2024 08:48:55 +0200 Subject: [PATCH] [IMP] web: add rounding methods This commit adds rounding methods that already exit in python: - DOWN: will alway round towards 0 - UP: will alway round away from 0 - 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 - HALF-UP: will round to the closest number with ties going away from zero (this method was the one implemented before this commit) task-3918420 --- addons/web/static/src/core/utils/numbers.js | 56 +++++-- .../static/tests/core/utils/numbers.test.js | 140 ++++++++++++++---- 2 files changed, 156 insertions(+), 40 deletions(-) diff --git a/addons/web/static/src/core/utils/numbers.js b/addons/web/static/src/core/utils/numbers.js index 4a115128ea1f9..2f04e61c58934 100644 --- a/addons/web/static/src/core/utils/numbers.js +++ b/addons/web/static/src/core/utils/numbers.js @@ -33,32 +33,62 @@ export function range(start, stop, step = 1) { } /** - * performs a half up rounding with arbitrary precision, correcting for float loss of precision - * See the corresponding float_round() in server/tools/float_utils.py for more info + * Returns `value` rounded with `precision`, minimizing IEEE-754 floating point + * representation errors, and applying the tie-breaking rule selected with + * `method`, by default "HALF-UP" (away from zero). * * @param {number} value the value to be rounded * @param {number} precision a precision parameter. eg: 0.01 rounds to two digits. + * @param {"HALF-UP" | "HALF-DOWN" | "HALF-EVEN" | "UP" | "DOWN"} [method="HALF-UP"] the rounding method used: + * - "HALF-UP" round to the closest number with ties going away from zero. + * - "HALF-DOWN" round to the closest number with ties going towards zero. + * - "HALF-EVEN" round to the closest number with ties going to the closest even number. + * - "UP" always round away from 0. + * - "DOWN" always round towards 0. */ -export function roundPrecision(value, precision) { +export function roundPrecision(value, precision, method = "HALF-UP") { if (!value) { return 0; } else if (!precision || precision < 0) { precision = 1; } let normalizedValue = value / precision; + const sign = Math.sign(normalizedValue); const epsilonMagnitude = Math.log2(Math.abs(normalizedValue)); const epsilon = Math.pow(2, epsilonMagnitude - 52); - normalizedValue += normalizedValue >= 0 ? epsilon : -epsilon; + let roundedValue = normalizedValue; + + switch (method) { + case "DOWN": { + normalizedValue += sign * epsilon; + roundedValue = sign * Math.floor(Math.abs(normalizedValue)); + break; + } + case "HALF-DOWN": { + normalizedValue -= sign * epsilon; + roundedValue = sign * Math.round(Math.abs(normalizedValue)); + break; + } + case "HALF-UP": { + normalizedValue += sign * epsilon; + roundedValue = sign * Math.round(Math.abs(normalizedValue)); + break; + } + case "HALF-EVEN": { + const r = Math.round(normalizedValue); + roundedValue = Math.abs(normalizedValue) % 1 === 0.5 ? (r % 2 === 0 ? r : r - 1) : r; + break; + } + case "UP": { + normalizedValue -= sign * epsilon; + roundedValue = sign * Math.ceil(Math.abs(normalizedValue)); + break; + } + default: { + throw new Error(`Unknown rounding method: ${method}`); + } + } - /** - * Javascript performs strictly the round half up method, which is asymmetric. However, in - * Python, the method is symmetric. For example: - * - In JS, Math.round(-0.5) is equal to -0. - * - In Python, round(-0.5) is equal to -1. - * We want to keep the Python behavior for consistency. - */ - const sign = normalizedValue < 0 ? -1.0 : 1.0; - const roundedValue = sign * Math.round(Math.abs(normalizedValue)); return roundedValue * precision; } diff --git a/addons/web/static/tests/core/utils/numbers.test.js b/addons/web/static/tests/core/utils/numbers.test.js index 8f0dbb5f31bdd..186a4502adcf6 100644 --- a/addons/web/static/tests/core/utils/numbers.test.js +++ b/addons/web/static/tests/core/utils/numbers.test.js @@ -33,33 +33,119 @@ test("range", () => { expect(range(1, -4, 1)).toEqual([]); }); -test("roundPrecision", () => { - expect(roundPrecision(1.0, 1)).toBe(1); - expect(roundPrecision(1.0, 0.1)).toBe(1); - expect(roundPrecision(1.0, 0.01)).toBe(1); - expect(roundPrecision(1.0, 0.001)).toBe(1); - expect(roundPrecision(1.0, 0.0001)).toBe(1); - expect(roundPrecision(1.0, 0.00001)).toBe(1); - expect(roundPrecision(1.0, 0.000001)).toBe(1); - expect(roundPrecision(1.0, 0.0000001)).toBe(1); - expect(roundPrecision(1.0, 0.00000001)).toBe(1); - expect(roundPrecision(0.5, 1)).toBe(1); - expect(roundPrecision(-0.5, 1)).toBe(-1); - expect(roundPrecision(2.6745, 0.001)).toBe(2.6750000000000003); - expect(roundPrecision(-2.6745, 0.001)).toBe(-2.6750000000000003); - expect(roundPrecision(2.6744, 0.001)).toBe(2.674); - expect(roundPrecision(-2.6744, 0.001)).toBe(-2.674); - expect(roundPrecision(0.0004, 0.001)).toBe(0); - expect(roundPrecision(-0.0004, 0.001)).toBe(0); - expect(roundPrecision(357.4555, 0.001)).toBe(357.456); - expect(roundPrecision(-357.4555, 0.001)).toBe(-357.456); - expect(roundPrecision(457.4554, 0.001)).toBe(457.455); - expect(roundPrecision(-457.4554, 0.001)).toBe(-457.455); - expect(roundPrecision(-457.4554, 0.05)).toBe(-457.45000000000005); - expect(roundPrecision(457.444, 0.5)).toBe(457.5); - expect(roundPrecision(457.3, 5)).toBe(455); - expect(roundPrecision(457.5, 5)).toBe(460); - expect(roundPrecision(457.1, 3)).toBe(456); +describe("roundPrecision", () => { + test("default method (HALF-UP)", () => { + expect(roundPrecision(1.0, 1)).toBe(1); + expect(roundPrecision(1.0, 0.1)).toBe(1); + expect(roundPrecision(1.0, 0.01)).toBe(1); + expect(roundPrecision(1.0, 0.001)).toBe(1); + expect(roundPrecision(1.0, 0.0001)).toBe(1); + expect(roundPrecision(1.0, 0.00001)).toBe(1); + expect(roundPrecision(1.0, 0.000001)).toBe(1); + expect(roundPrecision(1.0, 0.0000001)).toBe(1); + expect(roundPrecision(1.0, 0.00000001)).toBe(1); + expect(roundPrecision(0.5, 1)).toBe(1); + expect(roundPrecision(-0.5, 1)).toBe(-1); + expect(roundPrecision(2.6745, 0.001)).toBe(2.6750000000000003); + expect(roundPrecision(-2.6745, 0.001)).toBe(-2.6750000000000003); + expect(roundPrecision(2.6744, 0.001)).toBe(2.674); + expect(roundPrecision(-2.6744, 0.001)).toBe(-2.674); + expect(roundPrecision(0.0004, 0.001)).toBe(0); + expect(roundPrecision(-0.0004, 0.001)).toBe(0); + expect(roundPrecision(357.4555, 0.001)).toBe(357.456); + expect(roundPrecision(-357.4555, 0.001)).toBe(-357.456); + expect(roundPrecision(457.4554, 0.001)).toBe(457.455); + expect(roundPrecision(-457.4554, 0.001)).toBe(-457.455); + expect(roundPrecision(-457.4554, 0.05)).toBe(-457.45000000000005); + expect(roundPrecision(457.444, 0.5)).toBe(457.5); + expect(roundPrecision(457.3, 5)).toBe(455); + expect(roundPrecision(457.5, 5)).toBe(460); + expect(roundPrecision(457.1, 3)).toBe(456); + + expect(roundPrecision(2.6735, 0.001)).toBe(2.674); + expect(roundPrecision(-2.6735, 0.001)).toBe(-2.674); + expect(roundPrecision(2.6745, 0.001)).toBe(2.6750000000000003); + expect(roundPrecision(-2.6745, 0.001)).toBe(-2.6750000000000003); + expect(roundPrecision(2.6744, 0.001)).toBe(2.674); + expect(roundPrecision(-2.6744, 0.001)).toBe(-2.674); + expect(roundPrecision(0.0004, 0.001)).toBe(0); + expect(roundPrecision(-0.0004, 0.001)).toBe(-0); + expect(roundPrecision(357.4555, 0.001)).toBe(357.456); + expect(roundPrecision(-357.4555, 0.001)).toBe(-357.456); + expect(roundPrecision(457.4554, 0.001)).toBe(457.455); + expect(roundPrecision(-457.4554, 0.001)).toBe(-457.455); + }); + + test("DOWN", () => { + // We use 2.425 because when normalizing 2.425 with precision=0.001 it gives + // us 2424.9999999999995 as value, and if not handle correctly the rounding DOWN + // value will be incorrect (should be 2.425 and not 2.424) + expect(roundPrecision(2.425, 0.001, "DOWN")).toBe(2.4250000000000003); + expect(roundPrecision(2.4249, 0.001, "DOWN")).toBe(2.424); + expect(roundPrecision(-2.425, 0.001, "DOWN")).toBe(-2.4250000000000003); + expect(roundPrecision(-2.4249, 0.001, "DOWN")).toBe(-2.424); + expect(roundPrecision(-2.5, 0.001, "DOWN")).toBe(-2.5); + expect(roundPrecision(1.8, 1, "DOWN")).toBe(1); + expect(roundPrecision(-1.8, 1, "DOWN")).toBe(-1); + }); + + test("HALF-DOWN", () => { + expect(roundPrecision(2.6735, 0.001, "HALF-DOWN")).toBe(2.673); + expect(roundPrecision(-2.6735, 0.001, "HALF-DOWN")).toBe(-2.673); + expect(roundPrecision(2.6745, 0.001, "HALF-DOWN")).toBe(2.674); + expect(roundPrecision(-2.6745, 0.001, "HALF-DOWN")).toBe(-2.674); + expect(roundPrecision(2.6744, 0.001, "HALF-DOWN")).toBe(2.674); + expect(roundPrecision(-2.6744, 0.001, "HALF-DOWN")).toBe(-2.674); + expect(roundPrecision(0.0004, 0.001, "HALF-DOWN")).toBe(0); + expect(roundPrecision(-0.0004, 0.001, "HALF-DOWN")).toBe(-0); + expect(roundPrecision(357.4555, 0.001, "HALF-DOWN")).toBe(357.455); + expect(roundPrecision(-357.4555, 0.001, "HALF-DOWN")).toBe(-357.455); + expect(roundPrecision(457.4554, 0.001, "HALF-DOWN")).toBe(457.455); + expect(roundPrecision(-457.4554, 0.001, "HALF-DOWN")).toBe(-457.455); + }); + + test("HALF-UP", () => { + expect(roundPrecision(2.6735, 0.001, "HALF-UP")).toBe(2.674); + expect(roundPrecision(-2.6735, 0.001, "HALF-UP")).toBe(-2.674); + expect(roundPrecision(2.6745, 0.001, "HALF-UP")).toBe(2.6750000000000003); + expect(roundPrecision(-2.6745, 0.001, "HALF-UP")).toBe(-2.6750000000000003); + expect(roundPrecision(2.6744, 0.001, "HALF-UP")).toBe(2.674); + expect(roundPrecision(-2.6744, 0.001, "HALF-UP")).toBe(-2.674); + expect(roundPrecision(0.0004, 0.001, "HALF-UP")).toBe(0); + expect(roundPrecision(-0.0004, 0.001, "HALF-UP")).toBe(-0); + expect(roundPrecision(357.4555, 0.001, "HALF-UP")).toBe(357.456); + expect(roundPrecision(-357.4555, 0.001, "HALF-UP")).toBe(-357.456); + expect(roundPrecision(457.4554, 0.001, "HALF-UP")).toBe(457.455); + expect(roundPrecision(-457.4554, 0.001, "HALF-UP")).toBe(-457.455); + }); + + test("HALF-EVEN", () => { + expect(roundPrecision(2.6735, 0.001, "HALF-EVEN")).toBe(2.674); + expect(roundPrecision(-2.6735, 0.001, "HALF-EVEN")).toBe(-2.674); + expect(roundPrecision(2.6745, 0.001, "HALF-EVEN")).toBe(2.674); + expect(roundPrecision(-2.6745, 0.001, "HALF-EVEN")).toBe(-2.674); + expect(roundPrecision(2.6744, 0.001, "HALF-EVEN")).toBe(2.674); + expect(roundPrecision(-2.6744, 0.001, "HALF-EVEN")).toBe(-2.674); + expect(roundPrecision(0.0004, 0.001, "HALF-EVEN")).toBe(0); + expect(roundPrecision(-0.0004, 0.001, "HALF-EVEN")).toBe(-0); + expect(roundPrecision(357.4555, 0.001, "HALF-EVEN")).toBe(357.455); + expect(roundPrecision(-357.4555, 0.001, "HALF-EVEN")).toBe(-357.455); + expect(roundPrecision(457.4554, 0.001, "HALF-EVEN")).toBe(457.455); + expect(roundPrecision(-457.4554, 0.001, "HALF-EVEN")).toBe(-457.455); + }); + + test("UP", () => { + // We use 8.175 because when normalizing 8.175 with precision=0.001 it gives + // us 8175,0000000001234 as value, and if not handle correctly the rounding UP + // value will be incorrect (should be 8,175 and not 8,176) + expect(roundPrecision(8.175, 0.001, "UP")).toBe(8.175); + expect(roundPrecision(8.1751, 0.001, "UP")).toBe(8.176); + expect(roundPrecision(-8.175, 0.001, "UP")).toBe(-8.175); + expect(roundPrecision(-8.1751, 0.001, "UP")).toBe(-8.176); + expect(roundPrecision(-6.0, 0.001, "UP")).toBe(-6); + expect(roundPrecision(1.8, 1, "UP")).toBe(2); + expect(roundPrecision(-1.8, 1, "UP")).toBe(-2); + }); }); test("roundDecimals", () => {