18

I want to emulate this function. I want to round a floating point number down to the nearest multiple of 0.05 (or generally to the nearest multiple of anything).

I want this:

>>> round_nearest(1.29, 0.05)
1.25

>>> round_nearest(1.30, 0.05)
1.30

I can do this:

import math

def round_nearest(n, r):
    return n - math.fmod(n, r)

>>> round_nearest(1.27, 0.05)
1.25  # Correct!

>>> round_nearest(1.30, 0.05)
1.25  # Incorrect! Correct would be 1.30.

The incorrect answer above is presumably due to floating point rounding. I could put some special case check to see if the n is "close enough" to a multiple of r and not do the subtraction, and that would probably work, but is there a better way? Or is this strategy the best option?

Asclepius
  • 57,944
  • 17
  • 167
  • 143
Andrew Magee
  • 6,506
  • 4
  • 35
  • 58

4 Answers4

33

You can round down to the nearest multiple of a like this:

def round_down(x, a):
    return math.floor(x / a) * a

You can round to the nearest multiple of a like this:

def round_nearest(x, a):
    return round(x / a) * a
Paul Hankin
  • 54,811
  • 11
  • 92
  • 118
  • 1
    This does not seem to work for me. In Python 3.9.5, `round_down(4.6, 0.2)` returns `4.4` whereas I would expect it to return `4.6`. – nimble_ninja Sep 15 '21 at 15:28
  • @nimble_ninja Consider my [answer](https://stackoverflow.com/a/70210770/). It returns 4.6 for `round_down(4.6, 0.2)` as is expected. – Asclepius Dec 03 '21 at 23:49
23

As Paul wrote:

You can round to the nearest multiple of a like this:

def round_nearest(x, a):
    return round(x / a) * a

Works nearly perfectly, but round_nearest(1.39, 0.05) gives 1.4000000000000001. To avoid it I'll recommend to do:

import math

def round_nearest2(x, a):
    return round(round(x / a) * a, -int(math.floor(math.log10(a))))

Which rounds to precision a, and then to number of significant digits, that has your precision a

EDIT

As @Asclepius shown this code has limitation to the first digit in precision (meaning that e.g. if you put 4.3 then rounding is done to closest integer, if you put 0.25 then number is rounded to first decimal digit after all. This can be easily fix by finding how many digits actually precision contains, and rounding to this number after all:

def round_nearest(x, a):
    max_frac_digits = 100
    for i in range(max_frac_digits):
        if round(a, -int(math.floor(math.log10(a))) + i) == a:
            frac_digits = -int(math.floor(math.log10(a))) + i
            break
    return round(round(x / a) * a, frac_digits)

frac_digits is rounded log10 of your precision (nearest number), so it basically shows how many fractional digits should be taken into account (or in case of bigger number - integer digits). So if your precision is 0.25 then frac_digits will be equal to 2, because of 2 fractional digits. If your precision is 40 then frac_digits will be equal to -1, because you need to 'go back' one digit from decimal separator.

Grysik
  • 807
  • 7
  • 16
  • Reusing the configuration of `a` doesn't seem wise. For example, `round_nearest2(82.0, 4.3)` should return 81.7 but it returns 82.0. – Asclepius Dec 04 '21 at 15:23
  • 1
    @Asclepius this was code limitation to first digit of precision, I haven't thought about multidigit precision. You can check updated answer, now also supports multiple digit precision. – Grysik Apr 15 '22 at 07:08
5

The previous answer by Paul fails the test round_down(4.6, 0.2) == 4.6.

This answer has two types of solutions, inexact and exact. They pass all previous tests and more, also with negative numbers. Each approach provides solutions for round_nearest, round_down, and round_up.

As a disclaimer, these solutions require a lot more testing. Where math.isclose is used, its default tolerances apply.

Can you find a failing example?

To devise additional exact solutions, consider this reference.

Using round (inexact)

import math

def round_nearest(num: float, to: float) -> float:
    return round(num / to) * to  # Credited to Paul H.

def round_down(num: float, to: float) -> float:
    nearest = round_nearest(num, to)
    if math.isclose(num, nearest): return num
    return nearest if nearest < num else nearest - to

def round_up(num: float, to: float) -> float:
    nearest = round_nearest(num, to)
    if math.isclose(num, nearest): return num
    return nearest if nearest > num else nearest + to

# Tests:
rn, rd, ru = round_nearest, round_down, round_up

> rn(1.27, 0.05), rn(1.29, 0.05), rn(1.30, 0.05), rn(1.39, 0.05)
(1.25, 1.3, 1.3, 1.4000000000000001)
> rn(-1.27, 0.05), rn(-1.29, 0.05), rn(-1.30, 0.05), rn(-1.39, 0.05)
(-1.25, -1.3, -1.3, -1.4000000000000001)
> rn(4.4, 0.2), rn(4.5, 0.2), rn(4.6, 0.2)
(4.4, 4.4, 4.6000000000000005)
> rn(-4.4, 0.2), rn(-4.5, 0.2), rn(-4.6, 0.2)
(-4.4, -4.4, -4.6000000000000005)
> rn(82, 4.3)
81.7

> rd(1.27, 0.05), rd(1.29, 0.05), rd(1.30, 0.05)
(1.25, 1.25, 1.3)
> rd(-1.27, 0.05), rd(-1.29, 0.05), rd(-1.30, 0.05)
(-1.3, -1.3, -1.3)
> rd(4.4, 0.2), rd(4.5, 0.2), rd(4.6, 0.2)
(4.4, 4.4, 4.6)
> rd(-4.4, 0.2), rd(-4.5, 0.2), rd(-4.6, 0.2)
(-4.4, -4.6000000000000005, -4.6)

> ru(1.27, 0.05), ru(1.29, 0.05), ru(1.30, 0.05)
(1.3, 1.3, 1.3)
> ru(-1.27, 0.05), ru(-1.29, 0.05), ru(-1.30, 0.05)
(-1.25, -1.25, -1.3)
> ru(4.4, 0.2), ru(4.5, 0.2), ru(4.6, 0.2)
(4.4, 4.6000000000000005, 4.6)
> ru(-4.4, 0.2), ru(-4.5, 0.2), ru(-4.6, 0.2)
(-4.4, -4.4, -4.6)

Using math.fmod (inexact)

import math

def round_down(num: float, to: float) -> float:
    if num < 0: return -round_up(-num, to)
    mod = math.fmod(num, to)
    return num if math.isclose(mod, to) else num - mod

def round_up(num: float, to: float) -> float:
    if num < 0: return -round_down(-num, to)
    down = round_down(num, to)
    return num if num == down else down + to

def round_nearest(num: float, to: float) -> float:
    down, up = round_down(num, to), round_up(num, to)
    return down if ((num - down) < (up - num)) else up

# Tests:
rd, ru, rn = round_down, round_up, round_nearest

> rd(1.27, 0.05), rd(1.29, 0.05), rd(1.30, 0.05)
(1.25, 1.25, 1.3)
> rd(-1.27, 0.05), rd(-1.29, 0.05), rd(-1.30, 0.05)
(-1.3, -1.3, -1.3)
> rd(4.4, 0.2), rd(4.5, 0.2), rd(4.6, 0.2)
(4.4, 4.4, 4.6)
> rd(-4.4, 0.2), rd(-4.5, 0.2), rd(-4.6, 0.2)
(-4.4, -4.6000000000000005, -4.6)

> ru(1.27, 0.05), ru(1.29, 0.05), ru(1.30, 0.05)
(1.3, 1.3, 1.3)
> ru(-1.27, 0.05), ru(-1.29, 0.05), ru(-1.30, 0.05)
(-1.25, -1.25, -1.3)
> ru(4.4, 0.2), ru(4.5, 0.2), ru(4.6, 0.2)
(4.4, 4.6000000000000005, 4.6)
> ru(-4.4, 0.2), ru(-4.5, 0.2), ru(-4.6, 0.2)
(-4.4, -4.4, -4.6)

> rn(1.27, 0.05), rn(1.29, 0.05), rn(1.30, 0.05), rn(1.39, 0.05)
(1.25, 1.3, 1.3, 1.4000000000000001)
> rn(-1.27, 0.05), rn(-1.29, 0.05), rn(-1.30, 0.05), rn(-1.39, 0.05)
(-1.25, -1.3, -1.3, -1.4000000000000001)
> rn(4.4, 0.2), rn(4.5, 0.2), rn(4.6, 0.2)
(4.4, 4.4, 4.6)
> rn(-4.4, 0.2), rn(-4.5, 0.2), rn(-4.6, 0.2)
(-4.4, -4.4, -4.6)
> rn(82, 4.3)
81.7

Using math.remainder (inexact)

This section implements only round_nearest. For round_down and round_up, use the same exact logic as in the "Using round" section.

def round_nearest(num: float, to: float) -> float:
    return num - math.remainder(num, to)

# Tests:
rn = round_nearest

> rn(1.27, 0.05), rn(1.29, 0.05), rn(1.30, 0.05), rn(1.39, 0.05)
(1.25, 1.3, 1.3, 1.4000000000000001)
> rn(-1.27, 0.05), rn(-1.29, 0.05), rn(-1.30, 0.05), rn(-1.39, 0.05)
(-1.25, -1.3, -1.3, -1.4000000000000001)
> rn(4.4, 0.2), rn(4.5, 0.2), rn(4.6, 0.2)
(4.4, 4.4, 4.6000000000000005)
> rn(-4.4, 0.2), rn(-4.5, 0.2), rn(-4.6, 0.2)
(-4.4, -4.4, -4.6000000000000005)
> rn(82, 4.3)
81.7

Using decimal.Decimal (exact)

Note that this is an inefficient solution because it uses str.

from decimal import Decimal
import math

def round_nearest(num: float, to: float) -> float:
    num, to = Decimal(str(num)), Decimal(str(to))
    return float(round(num / to) * to)

def round_down(num: float, to: float) -> float:
    num, to = Decimal(str(num)), Decimal(str(to))
    return float(math.floor(num / to) * to)

def round_up(num: float, to: float) -> float:
    num, to = Decimal(str(num)), Decimal(str(to))
    return float(math.ceil(num / to) * to)

# Tests:
rn, rd, ru = round_nearest, round_down, round_up

> rn(1.27, 0.05), rn(1.29, 0.05), rn(1.30, 0.05), rn(1.39, 0.05)
(1.25, 1.3, 1.3, 1.4)
> rn(-1.27, 0.05), rn(-1.29, 0.05), rn(-1.30, 0.05), rn(-1.39, 0.05)
(-1.25, -1.3, -1.3, -1.4)
> rn(4.4, 0.2), rn(4.5, 0.2), rn(4.6, 0.2)
(4.4, 4.4, 4.6)
> rn(-4.4, 0.2), rn(-4.5, 0.2), rn(-4.6, 0.2)
(-4.4, -4.4, -4.6)
> rn(82, 4.3)
81.7

> rd(1.27, 0.05), rd(1.29, 0.05), rd(1.30, 0.05)
(1.25, 1.25, 1.3)
> rd(-1.27, 0.05), rd(-1.29, 0.05), rd(-1.30, 0.05)
(-1.3, -1.3, -1.3)
> rd(4.4, 0.2), rd(4.5, 0.2), rd(4.6, 0.2)
(4.4, 4.4, 4.6)
> rd(-4.4, 0.2), rd(-4.5, 0.2), rd(-4.6, 0.2)
(-4.4, -4.6, -4.6)

> ru(1.27, 0.05), ru(1.29, 0.05), ru(1.30, 0.05)
(1.3, 1.3, 1.3)
> ru(-1.27, 0.05), ru(-1.29, 0.05), ru(-1.30, 0.05)
(-1.25, -1.25, -1.3)
> ru(4.4, 0.2), ru(4.5, 0.2), ru(4.6, 0.2)
(4.4, 4.6, 4.6)
> ru(-4.4, 0.2), ru(-4.5, 0.2), ru(-4.6, 0.2)
(-4.4, -4.4, -4.6)

Using fractions.Fraction (exact)

Note that this is an inefficient solution because it uses str. Its test results are identical to those in the "Using decimal.Decimal" section. In my benchmarks, approaches using Fraction were much slower than those using Decimal.

from fractions import Fraction
import math

def round_nearest(num: float, to: float) -> float:
    num, to = Fraction(str(num)), Fraction(str(to))
    return float(round(num / to) * to)

def round_down(num: float, to: float) -> float:
    num, to = Fraction(str(num)), Fraction(str(to))
    return float(math.floor(num / to) * to)

def round_up(num: float, to: float) -> float:
    num, to = Fraction(str(num)), Fraction(str(to))
    return float(math.ceil(num / to) * to)
Asclepius
  • 57,944
  • 17
  • 167
  • 143
0
def round_nearest(x, a):
  return round(round(x / a) * a, 2)

It is a slightly different variation.

Asclepius
  • 57,944
  • 17
  • 167
  • 143
Amit Ghosh
  • 1,500
  • 13
  • 18
  • Hardcoding 2 makes this answer unable to generalize beyond 0.05. As a trivial example, for `round_nearest(.124, .005)`, it produces 0.12, whereas the correct answer would be 0.125. – Asclepius Dec 03 '21 at 23:57