Merge pull request #20258 from ankush/rounding_methods

feat: configurable rounding methods
This commit is contained in:
Ankush Menat 2023-03-08 14:24:25 +05:30 committed by GitHub
commit c8a641d72d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 245 additions and 23 deletions

View file

@ -0,0 +1,44 @@
context("Rounding behaviour", () => {
before(() => {
cy.login();
cy.visit("/app/");
});
it("Rounds floats accurately", () => {
cy.window()
.its("flt")
.then((flt) => {
let rounding_method = "Rounding Half Away From Zero";
expect(flt("0.5", 0, null, rounding_method)).eq(1);
expect(flt("0.3", null, null, rounding_method)).eq(0.3);
expect(flt("1.5", 0, null, rounding_method)).eq(2);
// positive rounding to integers
expect(flt(0.4, 0, null, rounding_method)).eq(0);
expect(flt(0.5, 0, null, rounding_method)).eq(1);
expect(flt(1.455, 0, null, rounding_method)).eq(1);
expect(flt(1.5, 0, null, rounding_method)).eq(2);
// negative rounding to integers
expect(flt(-0.5, 0, null, rounding_method)).eq(-1);
expect(flt(-1.5, 0, null, rounding_method)).eq(-2);
// negative precision i.e. round to nearest 10th
expect(flt(123, -1, null, rounding_method)).eq(120);
expect(flt(125, -1, null, rounding_method)).eq(130);
expect(flt(134.45, -1, null, rounding_method)).eq(130);
expect(flt(135, -1, null, rounding_method)).eq(140);
// positive multiple digit rounding
expect(flt(1.25, 1, null, rounding_method)).eq(1.3);
expect(flt(0.15, 1, null, rounding_method)).eq(0.2);
expect(flt(2.675, 2, null, rounding_method)).eq(2.68);
// negative multiple digit rounding
expect(flt(-1.25, 1, null, rounding_method)).eq(-1.3);
expect(flt(-0.15, 1, null, rounding_method)).eq(-0.2);
});
});
});

View file

@ -39,4 +39,21 @@ frappe.ui.form.on("System Settings", {
first_day_of_the_week(frm) {
frm.re_setup_moment = true;
},
rounding_method: function (frm) {
if (frm.doc.rounding_method == frappe.boot.sysdefaults.rounding_method) return;
let msg = __(
"Changing rounding method on site with data can result in unexpected behaviour."
);
msg += "<br>";
msg += __("Do you still want to proceed?");
frappe.confirm(
msg,
() => {},
() => {
frm.set_value("rounding_method", frappe.boot.sysdefaults.rounding_method);
}
);
},
});

View file

@ -17,10 +17,11 @@
"date_format",
"time_format",
"number_format",
"first_day_of_the_week",
"column_break_7",
"float_precision",
"currency_precision",
"first_day_of_the_week",
"rounding_method",
"sec_backup_limit",
"backup_limit",
"encrypt_backup",
@ -520,12 +521,19 @@
"fieldname": "login_with_email_link_expiry",
"fieldtype": "Int",
"label": "Login with email link expiry (in minutes)"
},
{
"default": "Round Half Even",
"fieldname": "rounding_method",
"fieldtype": "Select",
"label": "Rounding Method",
"options": "Round Half Even\nRounding Half Away From Zero"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2022-12-20 21:45:37.651668",
"modified": "2023-03-06 11:31:19.144956",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
@ -544,4 +552,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View file

@ -273,6 +273,10 @@ class ExecutableNotFound(FileNotFoundError):
pass
class InvalidRoundingMethod(FileNotFoundError):
pass
class InvalidRemoteException(Exception):
pass

View file

@ -5,7 +5,7 @@ import "./datatype";
if (!window.frappe) window.frappe = {};
function flt(v, decimals, number_format) {
function flt(v, decimals, number_format, rounding_method) {
if (v == null || v == "") return 0;
if (!(typeof v === "number" || String(parseFloat(v)) == v)) {
@ -30,7 +30,7 @@ function flt(v, decimals, number_format) {
}
v = parseFloat(v);
if (decimals != null) return _round(v, decimals);
if (decimals != null) return _round(v, decimals, rounding_method);
return v;
}
@ -173,16 +173,40 @@ function get_number_format_info(format) {
return info;
}
function _round(num, precision) {
var is_negative = num < 0 ? true : false;
var d = cint(precision);
var m = Math.pow(10, d);
var n = +(d ? Math.abs(num) * m : Math.abs(num)).toFixed(8); // Avoid rounding errors
var i = Math.floor(n),
f = n - i;
var r = !precision && f == 0.5 ? (i % 2 == 0 ? i : i + 1) : Math.round(n);
r = d ? r / m : r;
return is_negative ? -r : r;
function _round(num, precision, rounding_method) {
rounding_method =
rounding_method || frappe.boot.sysdefaults.rounding_method || "Round Half Even";
let is_negative = num < 0 ? true : false;
if (rounding_method == "Round Half Even") {
var d = cint(precision);
var m = Math.pow(10, d);
var n = +(d ? Math.abs(num) * m : Math.abs(num)).toFixed(8); // Avoid rounding errors
var i = Math.floor(n),
f = n - i;
var r = !precision && f == 0.5 ? (i % 2 == 0 ? i : i + 1) : Math.round(n);
r = d ? r / m : r;
return is_negative ? -r : r;
} else if (rounding_method == "Rounding Half Away From Zero") {
if (num == 0) return 0.0;
let digits = cint(precision);
let multiplier = Math.pow(10, digits);
num = num * multiplier;
// For explanation of this method read python flt implementation notes.
let epsilon = 2.0 ** (Math.log2(Math.abs(num)) - 52.0);
if (is_negative) {
epsilon = -1 * epsilon;
}
num = Math.round(num + epsilon);
return num / multiplier;
} else {
throw new Error(`Unknown rounding method ${rounding_method}`);
}
}
function roundNumber(num, precision) {

View file

@ -6,25 +6,28 @@ import json
import os
import sys
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal, localcontext
from enum import Enum
from io import StringIO
from mimetypes import guess_type
from unittest.mock import patch
import pytz
from hypothesis import given
from hypothesis import strategies as st
from PIL import Image
import frappe
from frappe.installer import parse_app_name
from frappe.model.document import Document
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import (
ceil,
dict_to_str,
evaluate_filters,
execute_in_shell,
floor,
flt,
format_timedelta,
get_bench_path,
get_file_timestamp,
@ -1001,3 +1004,78 @@ class TestTBSanitization(FrappeTestCase):
self.assertIn("********", traceback)
self.assertIn("password =", traceback)
self.assertIn("safe_value", traceback)
class TestRounding(FrappeTestCase):
@change_settings("System Settings", {"rounding_method": "Rounding Half Away From Zero"})
def test_normal_rounding(self):
self.assertEqual(flt("what"), 0)
self.assertEqual(flt("0.5", 0), 1)
self.assertEqual(flt("0.3"), 0.3)
self.assertEqual(flt("1.5", 0), 2)
# positive rounding to integers
self.assertEqual(flt(0.4, 0), 0)
self.assertEqual(flt(0.5, 0), 1)
self.assertEqual(flt(1.455, 0), 1)
self.assertEqual(flt(1.5, 0), 2)
# negative rounding to integers
self.assertEqual(flt(-0.5, 0), -1)
self.assertEqual(flt(-1.5, 0), -2)
# negative precision i.e. round to nearest 10th
self.assertEqual(flt(123, -1), 120)
self.assertEqual(flt(125, -1), 130)
self.assertEqual(flt(134.45, -1), 130)
self.assertEqual(flt(135, -1), 140)
# positive multiple digit rounding
self.assertEqual(flt(1.25, 1), 1.3)
self.assertEqual(flt(0.15, 1), 0.2)
# negative multiple digit rounding
self.assertEqual(flt(-1.25, 1), -1.3)
self.assertEqual(flt(-0.15, 1), -0.2)
def test_normal_rounding_as_argument(self):
rounding_method = "Rounding Half Away From Zero"
self.assertEqual(flt("0.5", 0, rounding_method=rounding_method), 1)
self.assertEqual(flt("0.3", rounding_method=rounding_method), 0.3)
self.assertEqual(flt("1.5", 0, rounding_method=rounding_method), 2)
# positive rounding to integers
self.assertEqual(flt(0.4, 0, rounding_method=rounding_method), 0)
self.assertEqual(flt(0.5, 0, rounding_method=rounding_method), 1)
self.assertEqual(flt(1.455, 0, rounding_method=rounding_method), 1)
self.assertEqual(flt(1.5, 0, rounding_method=rounding_method), 2)
# negative rounding to integers
self.assertEqual(flt(-0.5, 0, rounding_method=rounding_method), -1)
self.assertEqual(flt(-1.5, 0, rounding_method=rounding_method), -2)
# negative precision i.e. round to nearest 10th
self.assertEqual(flt(123, -1, rounding_method=rounding_method), 120)
self.assertEqual(flt(125, -1, rounding_method=rounding_method), 130)
self.assertEqual(flt(134.45, -1, rounding_method=rounding_method), 130)
self.assertEqual(flt(135, -1, rounding_method=rounding_method), 140)
# positive multiple digit rounding
self.assertEqual(flt(1.25, 1, rounding_method=rounding_method), 1.3)
self.assertEqual(flt(0.15, 1, rounding_method=rounding_method), 0.2)
self.assertEqual(flt(2.675, 2, rounding_method=rounding_method), 2.68)
# negative multiple digit rounding
self.assertEqual(flt(-1.25, 1, rounding_method=rounding_method), -1.3)
self.assertEqual(flt(-0.15, 1, rounding_method=rounding_method), -0.2)
@change_settings("System Settings", {"rounding_method": "Rounding Half Away From Zero"})
@given(st.decimals(min_value=-1e8, max_value=1e8), st.integers(min_value=-2, max_value=4))
def test_normal_rounding_property(self, number, precision):
with localcontext() as ctx:
ctx.rounding = ROUND_HALF_UP
self.assertEqual(Decimal(str(flt(float(number), precision))), round(number, precision))

View file

@ -920,7 +920,9 @@ def flt(s: NumericType | str, precision: int | None = None) -> float:
...
def flt(s: NumericType | str, precision: int | None = None) -> float:
def flt(
s: NumericType | str, precision: int | None = None, rounding_method: str | None = None
) -> float:
"""Convert to float (ignoring commas in string)
:param s: Number in string or other numeric format.
@ -946,8 +948,10 @@ def flt(s: NumericType | str, precision: int | None = None) -> float:
try:
num = float(s)
if precision is not None:
num = rounded(num, precision)
except Exception:
num = rounded(num, precision, rounding_method)
except Exception as e:
if isinstance(e, frappe.InvalidRoundingMethod):
raise
num = 0.0
return num
@ -1046,12 +1050,28 @@ def sbool(x: str) -> bool | Any:
return x
def rounded(num, precision=0):
"""round method for round halfs to nearest even algorithm aka banker's rounding - compatible with python3"""
def rounded(num, precision=0, rounding_method=None):
"""Round according to method set in system setting, defaults to banker's rounding"""
precision = cint(precision)
multiplier = 10**precision
rounding_method = (
rounding_method or frappe.get_system_settings("rounding_method") or "Round Half Even"
)
if rounding_method == "Round Half Even":
return _round_half_even(num, precision)
elif rounding_method == "Rounding Half Away From Zero":
return _round_away_from_zero(num, precision)
else:
frappe.throw(
frappe._("Unknown Rounding Method: {}").format(rounding_method),
exc=frappe.InvalidRoundingMethod,
)
def _round_half_even(num, precision):
# avoid rounding errors
multiplier = 10**precision
num = round(num * multiplier if precision else num, 8)
floor_num = math.floor(num)
@ -1068,6 +1088,32 @@ def rounded(num, precision=0):
return (num / multiplier) if precision else num
def _round_away_from_zero(num, precision):
if num == 0:
return 0.0
# Epsilon is small correctional value added to correctly round numbers which can't be
# represented in IEEE 754 representation.
# In simplified terms, the representation optimizes for absolute errors in representation
# so if a number is not representable it might be represented by a value ever so slighly
# smaller than the value itself. This becomes a problem when breaking ties for numbers
# ending with 5 when it's represented by a smaller number. By adding a very small value
# close to what's "least count" or smallest representable difference in the scale we force
# the number to be bigger than actual value, this increases representation error but
# removes rounding error.
# References:
# - https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
# - https://docs.python.org/3/tutorial/floatingpoint.html#representation-error
# - https://docs.python.org/3/library/functions.html#round
# - easier to understand: https://www.youtube.com/watch?v=pQs_wx8eoQ8
epsilon = 2.0 ** (math.log(abs(num), 2) - 52.0)
return round(num + math.copysign(epsilon, num), precision)
def remainder(numerator: NumericType, denominator: NumericType, precision: int = 2) -> NumericType:
precision = cint(precision)
multiplier = 10**precision

View file

@ -102,3 +102,4 @@ Faker = "~=13.12.1"
pyngrok = "~=5.0.5"
unittest-xml-reporting = "~=3.0.4"
watchdog = "~=2.1.9"
hypothesis = "~=6.68.2"