Support rounding of datetime / a nicer way of rounding

Dear community,

Rounding of datetime has been a recurring issue for many (400,000+) users [ 1, 2, 3, 4 ] and the recommended solutions have been anything but pretty.

I would like to propose the following simple method for rounding to be added/merged into the stdlibs (where?).

from datetime import datetime, timedelta, timezone

epoch = datetime(2000,1,1,0,0,0,0,timezone.utc)
epoch_no_tz = datetime(2000,1,1,0,0,0,0)

def round(value, multiple, up=None):
        """ a nicer way to round numbers.

        :param value: float/integer
        :param multiple: base of the rounding.
        :param up: None (default) or boolean rounds half, up or down.
            round(1.6, 1) rounds to 2.
            round(1.4, 1) rounds to 1.
            round(1.5, 1, up=True) rounds to 2.
            round(1.5, 1, up=False) rounds to 1.
        :return: value

        Examples:
            multiple = 1 is the same as rounding to whole integers.
            multiple = 0.001 is the same as rounding to 3 digits precision.
            mulitple = 3.1415 is rounding to nearest multiplier of 3.1415

            dt = datetime(2022,8,18,11,14,53,440)
            td = timedelta(hours=0.5)
            round(dt,td) is datetime(2022,8,18,11,0)
        """
        epoch = 0
        if isinstance(value, (datetime)) and isinstance(multiple, timedelta):
            if value.tzinfo is None:
                epoch = DataTypes.epoch_no_tz
            else:
                epoch = DataTypes.epoch
        
        low = ((value - epoch) // multiple) * multiple
        high = low + multiple
        if up is True:
            return high + epoch
        elif up is False:
            return low + epoch
        else:
            if abs((high + epoch) - value) < abs(value-(low + epoch)):
                return high + epoch
            else:
                return low + epoch

Here is the test suite:

import datatypes   # the module above 
import math

def test_round():
    xround = datatypes.round  # xround is merely used to distinguish from common `round` method.
    # round up
    assert xround(1.6, 1, True) == 2
    assert xround(1.4, 1, True) == 2
    # round down
    assert xround(1.6, 1, False) == 1
    assert xround(1.4, 1, False) == 1
    # round half
    assert xround(1.6, 1) == 2
    assert xround(1.4, 1) == 1

    # round half
    assert xround(16, 10) == 20
    assert xround(14, 10) == 10

    # round half
    assert xround(-16, 10) == -20
    assert xround(-14, 10) == -10

    # round to odd multiples
    assert xround(6, 3.1415, 1) == 2 * 3.1415

    assert xround(1.2345, 0.001, True) == 1.2349999999999999 and math.isclose(1.2349999999999999, 1.235)
    assert xround(1.2345, 0.001, False) == 1.234

    assert xround(123, 100, False) == 100
    assert xround(123, 100, True) == 200

    assert xround(123, 5.07, False) == 24 * 5.07

    dt = datetime(2022,8,18,11,14,53,440)

    td = timedelta(hours=0.5)    
    assert xround(dt,td, up=False) == datetime(2022,8,18,11,0)
    assert xround(dt,td, up=None) == datetime(2022,8,18,11,0)
    assert xround(dt,td, up=True) == datetime(2022,8,18,11,30)

    td = timedelta(hours=24)
    assert xround(dt,td, up=False) == datetime(2022,8,18)
    assert xround(dt,td, up=None) == datetime(2022,8,18)
    assert xround(dt,td, up=True) == datetime(2022,8,19)


    td = timedelta(days=0.5)
    assert xround(dt,td, up=False) == datetime(2022,8,18)
    assert xround(dt,td, up=None) == datetime(2022,8,18,12)
    assert xround(dt,td, up=True) == datetime(2022,8,18,12)

    td = timedelta(days=1.5)
    assert xround(dt,td, up=False) == datetime(2022,8,18)
    assert xround(dt,td, up=None) == datetime(2022,8,18)
    assert xround(dt,td, up=True) == datetime(2022,8,19,12)

    td = timedelta(seconds=0.5)
    assert xround(dt,td, up=False) == datetime(2022,8,18,11,14,53,0)
    assert xround(dt,td, up=None) == datetime(2022,8,18,11,14,53,0)
    assert xround(dt,td, up=True) == datetime(2022,8,18,11,14,53,500000)

    td = timedelta(seconds=40000)
    assert xround(dt,td, up=False) == datetime(2022,8,18,6,40)
    assert xround(dt,td, up=None) == datetime(2022,8,18,6,40)
    assert xround(dt,td, up=True) == datetime(2022,8,18,17,46,40)

Kind regards
Dr. Bjorn Madsen

2 Likes

Python has a builtin function called round which rounds numeric types by calling their __round__ method (which should only be implemented if rounding makes sense for that type), see Built-in Functions ā€” Python 3.10.6 documentation and 3. Data model ā€” Python 3.10.6 documentation.

I am not sure your function fits well with the numeric protocol, a datetime object is very different and I donā€™t think that class should implement __round__. It makes more sense to me to add this functionality as a dateround/datetimeround function in the datetime module.

2 Likes

@ajoino

a datetime object is very different

How come? All the required mathematical functions are already implemented.

Iā€™m suggesting that we give this to the users, rather than letting them implement it on their own.
I believe this was the argument for the statistics module?

This would be the patch to the datetime class near line 2113:

epoch = datetime(2000,1,1,0,0,0,0,timezone.utc)  # any epoch will do for the code below.
epoch_no_tz = datetime(2000,1,1,0,0,0,0)


def __round__(self, value, multiple, up=None):  # self is the datetime class.
    if not isinstance(value, datetime):raise TypeError()
    if not isinstance(multiple, timedelta):raise TypeError()

    if value.tzinfo is None:
        epoch = epoch_no_tz
    else:
        epoch = epoch

    low = ((value - epoch) // multiple) * multiple
    high = low + multiple
    if up is True:
        return high + epoch
    elif up is False:
        return low + epoch
    elif abs((high + epoch) - value) < abs(value-(low + epoch)):
        return high + epoch
    else:
        return low + epoch
2 Likes

The complex builtin also implements (most) of those methods yet does not implement __round__. A datetime is a representation of an instance in time, which can be represented as an integer, but the datetime representation consists of 6+ parts: years, months, days, hours, minutes, seconds, timezone, and maybe some more. This makes rounding, in the sense of the round/__round__ protocol, which can only take a single int (or integer-like object) as input, not really possible imo.

Furthermore, your implementation of __round__ does not comply with the protocol. For datetime objects to be compliant with the round/__round__ protocol, it can only take one input which is integer-like (I believe this means that the object has a __int__ method). For your implementation to work, the round builtin would need to be redesigned.

I cannot think of any implementation of __round__ for a datetime object which correctly rounds all of the parts and takes only an integer-like object as input. Thus, I think itā€™s better to provide this functionality in the datetime module.

your implementation of __round__ does not comply with the protocol .

Accepted.

from datetime import datetime, timedelta, dateround

So what is the next step? Does this require a PEP?

2 Likes

Sorry if I was a bit curt, I do like the idea of a rounding for datetime :slight_smile:

I think the first step is to consult the maintainer of the datetime module, as this would increase the maintenance burden. Who that is however I do not know.

1 Like

No worries. :slight_smile:

Perhaps @ambv knows who the maintainer of the datetime module is?

2 Likes

Itā€™s @pganssle .

2 Likes

Thanks @brettcannon . I look forward to hear what @pganssle thinks.

Iā€™ll have to re-read the thread when I have some time, but I opened a similar bug some years ago, before I was very actively involved with CPython development: Add precision argument to datetime.now Ā· Issue #76703 Ā· python/cpython Ā· GitHub

Of course, the last entry in the thread says that Iā€™ll put together a summary and start a discussion topic ā€œin the next week or soā€ā€¦ in January 2018, and Iā€™m noticing this thread about 5 days before moving to a different city, so maybe the next time I stumble on this Iā€™ll be using my robot arm to type out a message explaining how someone had a similar idea on Discourse way back in 2022 :sweat_smile:.

1 Like

Hi @pganssle - time is drifting. May we count on you to add this feature to datetime?

Hi @pganssle - I donā€™t know if itā€™s inspirational, but Iā€™ve written a business calendar that you may (?) find useful? Here you go: link

The module / notebook allows a user to set up a business calendar and ask questions like:

  • is the business open ?
  • when is the business open next?

I need it for solving scheduling problems.

Hello! Iā€™m still hoping for this, or would be happy to implement it for you @pganssle .

1 Like

Hi @TheMellyBee & @pganssle Depending on which implementation you pick, be wary of testing for zero as well:

This implementation (link) has the whole test suite.

Kind regards
Bjorn

I find a ā€˜floorā€™ function more helpful than a ā€˜roundā€™ function. Plus, pandas already supports ā€˜floorā€™ on datetime64 series (pandas.Series.dt.floor ā€” pandas 1.5.2 documentation). it would be nice to add similar support to datetime.date and datetime.

Hello team, whatever happened with this one? Definitely agree that Python should implement floor and ceil for datetime similar to how Pandas has done it for a datetime series. Thanks very much!

1 Like

I like floor and ceiling too.

The users who asked for round came from ā€œrounding to multiple of Nā€, no matter what type N would be.
This made the universal ā€œroundā€ function implemented in tablite so popular.

I completely get that ceil(x/N)*N is the same as round(x, N, up=True), but hey thats the convenience of functions :slight_smile: