Skip to content

Commit

Permalink
fix: #1485 improve conversion of numbers with a round-off errors into…
Browse files Browse the repository at this point in the history
… a BigNumber
  • Loading branch information
josdejong committed Oct 26, 2023
1 parent f9ab3a1 commit 795dd1f
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 27 deletions.
16 changes: 5 additions & 11 deletions src/core/function/typed.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
* @returns {function} The created typed-function.
*/

import typedFunction from 'typed-function'
import { convertNumberToBigNumber } from '../../utils/bignumber/convertNumberToBigNumber.js'
import { factory } from '../../utils/factory.js'
import {
isAccessorNode,
isArray,
Expand Down Expand Up @@ -68,18 +71,15 @@ import {
isParenthesisNode,
isRange,
isRangeNode,
isRelationalNode,
isRegExp,
isRelationalNode,
isResultSet,
isSparseMatrix,
isString,
isSymbolNode,
isUndefined,
isUnit
} from '../../utils/is.js'
import typedFunction from 'typed-function'
import { digits } from '../../utils/number.js'
import { factory } from '../../utils/factory.js'
import { isMap } from '../../utils/map.js'

// returns a new instance of typed-function
Expand Down Expand Up @@ -173,13 +173,7 @@ export const createTyped = /* #__PURE__ */ factory('typed', dependencies, functi
throwNoBignumber(x)
}

// note: conversion from number to BigNumber can fail if x has >15 digits
if (digits(x) > 15) {
throw new TypeError('Cannot implicitly convert a number with >15 significant digits to BigNumber ' +
'(value: ' + x + '). ' +
'Use function bignumber(x) to convert to BigNumber.')
}
return new BigNumber(x)
return convertNumberToBigNumber(x, BigNumber)
}
}, {
from: 'number',
Expand Down
43 changes: 43 additions & 0 deletions src/utils/bignumber/convertNumberToBigNumber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { digits } from '../number.js'

/**
* Convert a number into a BigNumber when it is safe to do so: only when the number
* is has max 15 digits, since a JS number can only represent about ~16 digits.
* This function will correct round-off errors introduced by the JS floating-point
* operations. For example, it will change 0.1+0.2 = 0.30000000000000004 into 0.3.
*
* The function throws an Error when the number cannot be converted safely into a BigNumber.
*
* @param {number} x
* @param {function} BigNumber The bignumber constructor
* @returns {BigNumber}
*/
export function convertNumberToBigNumber (x, BigNumber) {
const d = digits(x)
if (d.length <= 15) {
return new BigNumber(x)
}

// recognize round-off errors like 0.1 + 0.2 = 0.30000000000000004, which should be 0.3
// we test whether the first 15 digits end with at least 6 zeros, and a non-zero last digit
// note that a number can optionally end with an exponent
const xStr = x.toString()
const matchTrailingZeros = xStr.match(/(?<start>.+)(?<zeros>0{6,}[1-9][0-9]*)(?<end>$|[+-eE])/)
if (matchTrailingZeros) {
const { start, end } = matchTrailingZeros.groups
return new BigNumber(start + end)
}

// recognize round-off errors like 40 - 38.6 = 1.3999999999999986, which should be 1.4
// we test whether the first 15 digits end with at least 6 nines, and a non-nine and non-zero last digit
// note that a number can optionally end with an exponent
const matchTrailingNines = xStr.match(/(?<start>.+)(?<digitBeforeNines>[0-8])(?<nines>9{6,}[1-8][0-9]*)(?<end>$|[+-eE])/)
if (matchTrailingNines) {
const { start, digitBeforeNines, end } = matchTrailingNines.groups
return new BigNumber(start + String(parseInt(digitBeforeNines) + 1) + end)
}

throw new TypeError('Cannot implicitly convert a number with >15 significant digits to BigNumber ' +
'(value: ' + x + '). ' +
'Use function bignumber(x) to convert to BigNumber.')
}
12 changes: 6 additions & 6 deletions src/utils/number.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,22 +572,22 @@ function zeros (length) {
}

/**
* Count the number of significant digits of a number.
* Extract all significant digits of a number.
*
* For example:
* 2.34 returns 3
* 0.0034 returns 2
* 120.5e+30 returns 4
* 2.34 returns '234'
* 0.0034 returns '34'
* 120.5e+30 returns '1205'
*
* @param {number} value
* @return {number} digits Number of significant digits
* @return {string} Returns a string with all digits
*/
export function digits (value) {
return value
.toExponential()
.replace(/^-/, '') // remove sign
.replace(/e.*$/, '') // remove exponential notation
.replace(/^0\.?0*|\./, '') // remove decimal point and leading zeros
.length
}

/**
Expand Down
35 changes: 35 additions & 0 deletions test/unit-tests/utils/bignumber/convertNumberToBigNumber.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'assert'
import BigNumber from 'decimal.js'
import { convertNumberToBigNumber } from '../../../../src/utils/bignumber/convertNumberToBigNumber.js'

describe('convertNumberToBigNumber', function () {
it('should convert numbers to BigNumbers when it is safe to do', function () {
assert.deepStrictEqual(convertNumberToBigNumber(2.4, BigNumber), new BigNumber('2.4'))
assert.deepStrictEqual(convertNumberToBigNumber(2, BigNumber), new BigNumber('2'))
assert.deepStrictEqual(convertNumberToBigNumber(-4, BigNumber), new BigNumber('-4'))
assert.deepStrictEqual(convertNumberToBigNumber(0.1234567, BigNumber), new BigNumber('0.1234567'))
assert.deepStrictEqual(convertNumberToBigNumber(0.12345678901234, BigNumber), new BigNumber('0.12345678901234'))
assert.deepStrictEqual(convertNumberToBigNumber(0.00000000000004, BigNumber), new BigNumber('0.00000000000004'))
assert.deepStrictEqual(convertNumberToBigNumber(1.2e-24, BigNumber), new BigNumber('1.2e-24'))
})

it('should convert numbers with round-off errors to BigNumbers when it is safe to do', function () {
// a round-off error above the actual value
assert.deepStrictEqual(convertNumberToBigNumber(0.1 + 0.2, BigNumber).toString(), '0.3') // 0.30000000000000004
assert.deepStrictEqual(convertNumberToBigNumber(0.1 + 0.24545, BigNumber).toString(), '0.34545') // 0.34545000000000003

// a round-off error below the actual value
assert.deepStrictEqual(convertNumberToBigNumber(40 - 38.6, BigNumber).toString(), '1.4') // 1.3999999999999986
assert.deepStrictEqual(convertNumberToBigNumber(159.119 - 159, BigNumber).toString(), '0.119') // 0.11899999999999977
assert.deepStrictEqual(convertNumberToBigNumber(159.11934444 - 159, BigNumber).toString(), '0.11934444') // 0.11934443999999189
})

it('should throw an error when converting an number to BigNumber when it is NOT safe to do', function () {
const errorRegex = /Cannot implicitly convert a number with >15 significant digits to BigNumber/

assert.throws(() => convertNumberToBigNumber(Math.PI, BigNumber), errorRegex)
assert.throws(() => convertNumberToBigNumber(1 / 3, BigNumber), errorRegex)
assert.throws(() => convertNumberToBigNumber(1 / 7, BigNumber), errorRegex)
assert.throws(() => convertNumberToBigNumber(0.1234567890123456, BigNumber), errorRegex)
})
})
21 changes: 11 additions & 10 deletions test/unit-tests/utils/number.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,17 @@ describe('number', function () {
})

it('should count the number of significant digits of a number', function () {
assert.strictEqual(digits(0), 0)
assert.strictEqual(digits(2), 1)
assert.strictEqual(digits(1234), 4)
assert.strictEqual(digits(2.34), 3)
assert.strictEqual(digits(3000), 1)
assert.strictEqual(digits(0.0034), 2)
assert.strictEqual(digits(120.5e50), 4)
assert.strictEqual(digits(1120.5e+50), 5)
assert.strictEqual(digits(120.52e-50), 5)
assert.strictEqual(digits(Math.PI), 16)
assert.strictEqual(digits(0), '')
assert.strictEqual(digits(2), '2')
assert.strictEqual(digits(1234), '1234')
assert.strictEqual(digits(2.34), '234')
assert.strictEqual(digits(3000), '3')
assert.strictEqual(digits(0.0034), '34')
assert.strictEqual(digits(120.5e50), '1205')
assert.strictEqual(digits(1120.5e+50), '11205')
assert.strictEqual(digits(120.52e-50), '12052')
assert.strictEqual(digits(-1234), '1234')
assert.strictEqual(digits(Math.PI), '3141592653589793')
})

it('should format a number using toFixed', function () {
Expand Down

0 comments on commit 795dd1f

Please sign in to comment.