74

Is there an easy way to round a Time down to the nearest 15 minutes?

This is what I'm currently doing. Is there an easier way to do it?

t = Time.new
rounded_t = Time.local(t.year, t.month, t.day, t.hour, t.min/15*15)
readonly
  • 343,444
  • 107
  • 203
  • 205

13 Answers13

138

You said "round down", so I'm not sure if you're actually looking for the round or the floor, but here's the code to do both. I think something like this reads really well if you add round_off and floor methods to the Time class. The added benefit is that you can more easily round by any time partition.

require 'active_support/core_ext/numeric' # from gem 'activesupport'

class Time
  # Time#round already exists with different meaning in Ruby 1.9
  def round_off(seconds = 60)
    Time.at((self.to_f / seconds).round * seconds).utc
  end

  def floor(seconds = 60)
    Time.at((self.to_f / seconds).floor * seconds).utc
  end
end

t = Time.now                    # => Thu Jan 15 21:26:36 -0500 2009
t.round_off(15.minutes)         # => Thu Jan 15 21:30:00 -0500 2009
t.floor(15.minutes)             # => Thu Jan 15 21:15:00 -0500 2009

Note: ActiveSupport was only necessary for the pretty 15.minutes argument. If you don't want that dependency, use 15 * 60 instead.

Ian Vaughan
  • 20,211
  • 13
  • 59
  • 79
Ryan McGeary
  • 235,892
  • 13
  • 95
  • 104
  • 3
    Not that Time objects already have a [round](http://www.ruby-doc.org/core-2.0/Time.html#method-i-round) method, so this could brake some existing code. (`p t.round(5).iso8601(10) #=> "2010-03-30T05:43:25.1234600000Z"`) – amoebe Mar 22 '13 at 15:35
  • 1
    @amoebe Good point. Time#round looks like it was added in Ruby 1.9. I renamed the method to `round_off` although, the built-in `Time#round` doesn't seem that valuable to me. – Ryan McGeary Mar 31 '13 at 23:38
  • 1
    I like the elegance of this solution, but wish it didn't experience the time zone shift alluded to by Jarno... – Ryan Oct 07 '13 at 02:45
  • updated require directive for recent activesupport gem – nurettin Jan 27 '14 at 11:45
  • 3
    If you need the *next* 15 minute interval, try a version with `ceil` def round_up(seconds = 60) Time.at((self.to_f / seconds).ceil * seconds) end – nessur Feb 28 '14 at 18:19
  • This gives unexpected results in some time zones. `Time.zone = "Kathmandu"; Time.zone.now.closest(30.minutes) # => Tue, 30 Sep 2014 07:15:00 NPT +05:45` - I'd have expected it to be 07:30 (given the current time is 07:17) – Alex Ghiculescu Sep 30 '14 at 01:17
  • See my answer below for a solution to that ^ – Alex Ghiculescu Sep 30 '14 at 01:39
  • The fix to get timezone support to work is to convert the result into UTC, as thats what the Time class works with, then Rails knows what to do and convert as required for the caller. – Ian Vaughan Mar 29 '16 at 12:51
23

I am not very familiar with the syntax of ruby but you can round down to the nearest 15 minutes using modulo. (i.e. x - (x modulo 15)). I would guess the syntax would be something like

t.min - ( t.min % 15)

This will make your set of possible values 0, 15, 30, and 45. Assuming 0 <= t.min <= 59.

cmsjr
  • 56,771
  • 11
  • 70
  • 62
  • 1
    Almost correct! The correct syntax would be: t.min - (t.min % 15).minutes – hcarreras Jan 13 '16 at 13:54
  • 1
    This may be a nitpick, but for me it would caused an error. This won't round to the nearest 15 minutes, but rather set the number of minutes to the nearest 15 minutes. The difference is that you would round 12:34:44.123 to 12:34:45.123 (ignoring fractional seconds). – hirowatari Oct 26 '20 at 13:38
22

I thought I would post another solution that provides rounding up and down to the nearest number of seconds given. Oh, and this does not change the time zone like some of the other solutions.

class Time
  def round(sec=1)
    down = self - (self.to_i % sec)
    up = down + sec

    difference_down = self - down
    difference_up = up - self

    if (difference_down < difference_up)
      return down
    else
      return up
    end
  end
end

t = Time.now                             # => Mon Nov 15 10:18:29 +0200 2010
t.round(15.minutes)                      # => Mon Nov 15 10:15:00 +0200 2010
t.round(20.minutes)                      # => Mon Nov 15 10:20:00 +0200 2010
t.round(60.minutes)                      # => Mon Nov 15 10:00:00 +0200 2010

ActiveSupport was used in the examples for the x.minutes feature. You can use 15 * 60 instead.

Methods floor and ceil can be easily implemented based on this solution.

Peter O.
  • 32,158
  • 14
  • 82
  • 96
Jarno Lamberg
  • 1,530
  • 12
  • 12
  • 2
    This is the only logical "rounding" example here. Thanks Jarno – Trip Aug 24 '12 at 08:36
  • This is a bit wacky. Doesnt work how I'd expect at all `t => Tue, 29 Dec 2015 11:46:00 GMT +00:00 t.round_to(1.minutes) => Tue, 29 Dec 2015 11:46:00 GMT +00:00 t.round_to(5.minutes) => Tue, 29 Dec 2015 11:45:00 GMT +00:00 t.round_to(10.minutes) => Tue, 29 Dec 2015 11:40:00 GMT +00:00 t.round_to(11.minutes) => Tue, 29 Dec 2015 11:45:00 GMT +00:00` – Ian Vaughan Mar 29 '16 at 11:09
  • From :46, the nearest 5 is :45, correct, but the nearest 10 is not :40, its :50. – Ian Vaughan Mar 29 '16 at 11:10
  • @IanVaughan: Which version of Ruby are you using? I can't recall the exact version but I think the original was written using 1.8.7, probably. – Jarno Lamberg Mar 30 '16 at 22:14
  • @IanVaughan: I made a gist (https://gist.github.com/jlamberg/576318a1d740924263d5ce5834e35cf4) to test it really quickly. I hate myself for saying this, but your example seemed to work for me apart from the last example, i.e. when rounding it to the nearest 11 minutes: for some yet-to-figure-out reason (daylight savings, maybe?), the results differed by one minute giving :44 and :45. Results of rounding to the nearest 5 and 10 were as expected -- :45 and :50. I'll see if I have time to dig this more in the next few days. – Jarno Lamberg Mar 30 '16 at 22:26
  • @IanVaughan You shouldn't round to `11.minutes`. It rounds to the nearest 11 minute mark since time 0 (`Time.at(0).utc #=> 1970-01-01 00:00:00 UTC`). So not the nearest since the start of the hour. This means you should only use this if your number fits exactly in an hour. eg. `15.minutes`, `10.seconds`, `2.minutes`, or if you're rounding greater values`4.hours`, `12.hours`, `3.months`, etc. – 3limin4t0r Aug 27 '18 at 23:26
19

I found a very readable solution;

This will round your time to the last rounded 15 minutes. You can change the 15.minutes to every timescale possible.

time = Time.now.to_i
Time.at(time - (time % 15.minutes))
Hosam Aly
  • 41,555
  • 36
  • 141
  • 182
jewilmeer
  • 1,926
  • 1
  • 12
  • 11
17

I wrote the Rounding gem to handle these sort of cases.

Rounding down a time becomes as simple as calling floor_to on the time. Rounding up and rounding to the nearest are also supported (ceil_to and round_to).

require "rounding"

Time.current.floor_to(15.minutes) # => Thu, 07 May 2015 16:45:00 UTC +00:00
Time.current.ceil_to(15.minutes)  # => Thu, 07 May 2015 17:00:00 UTC +00:00
Time.current.round_to(15.minutes) # => Thu, 07 May 2015 16:45:00 UTC +00:00

The time zone of the original time is preserved (UTC in this case). You don't need ActiveSupport loaded—you can write floor_to(15*60) and it will work fine. The gem uses Rational numbers to avoid rounding errors. You can round to the nearest Monday by providing an offset round_to(1.week, Time.parse("2015-5-4 00:00:00 UTC")). We use it in production.

I wrote a blog post that explains more. Hope you find it helpful.

Brian Hempel
  • 8,844
  • 2
  • 24
  • 19
11

Since Ruby allows arithmetic (in seconds) on Times, you can just do this:

t = Time.new
rounded_t = t-t.sec-t.min%15*60
Chuck
  • 234,037
  • 30
  • 302
  • 389
8

Preface

There's quite a few solutions here and I began to wonder about their efficiency (thou efficiency is probably not the most important aspect in this problem). I took some from here and threw in a couple of my own. (N.B. though the OP asked about rounding down to closest 15 minutes, I've done my comparitions and samples with just 1 minute / 60s for the sake of more simple samples).

Setup

Benchmark.bmbm do |x|
  x.report("to_f, /, floor, * and Time.at") { 1_000_000.times { Time.at((Time.now.to_f / 60).floor * 60) } }
  x.report("to_i, /, * and Time.at") { 1_000_000.times { Time.at((Time.now.to_i / 60) * 60) } }
  x.report("to_i, %, - and Time.at") { 1_000_000.times { t = Time.now.to_i; Time.at(t - (t % 60)) } }
  x.report("to_i, %, seconds and -") { 1_000_000.times { t = Time.now; t - (t.to_i % 60).seconds } }
  x.report("to_i, % and -") { 1_000_000.times { t = Time.now; t - (t.to_i % 60) } }
end

Results

Rehearsal -----------------------------------------------------------------
to_f, /, floor, * and Time.at   4.380000   0.010000   4.390000 (  4.393235)
to_i, /, * and Time.at          3.270000   0.010000   3.280000 (  3.277615)
to_i, %, - and Time.at          3.220000   0.020000   3.240000 (  3.233176)
to_i, %, seconds and -         10.860000   0.020000  10.880000 ( 10.893103)
to_i, % and -                   4.450000   0.010000   4.460000 (  4.460001)
------------------------------------------------------- total: 26.250000sec

                                    user     system      total        real
to_f, /, floor, * and Time.at   4.400000   0.020000   4.420000 (  4.419075)
to_i, /, * and Time.at          3.220000   0.000000   3.220000 (  3.226546)
to_i, %, - and Time.at          3.270000   0.020000   3.290000 (  3.275769)
to_i, %, seconds and -         10.910000   0.010000  10.920000 ( 10.924287)
to_i, % and -                   4.500000   0.010000   4.510000 (  4.513809)

Analysing the results

What to make of it? Well thing's might work faster or slower on your hardware, so don't take my computers word for it. As you can see, another thing is that, unless we do these operations on the scale of millions operations it's not going to make much difference which method you use as far as processing power goes (though, do note that for instance most cloud computing solutions provide very little processing power and thus the millions might be hundreds or tens of thousands in such environments).

Slowest, but probably the most readable solution

In that sense using clearly the slowest of them t = Time.now; t - (t.to_i % 60).seconds can be justified just because the .seconds is so cool in there.

Not so slow and almost as readable solution

However, since it's actually not needed at all and makes the operation over twice as expensive as without it I have to say that my choice is the t = Time.now; t - (t.to_i % 60). In my opinion it is fast enough and million times more readable than any of the other solutions presented here. That is why I think it's the best solution for you casual flooring needs, though it is a significantly slower than the three other ones.

Awkward and not particularly slow or fast

The most voted solution on this page Time.at((Time.now.to_f / 60).floor * 60) is the slowest of all solutions on this page (before this answer) and significantly slower than the top 2 solutions. Using floats just to be able to floor the decimals away also seems very illogical. For the rounding part that would be ok, but rounding down sounds like "flooring" to me. If anything the counterpart for it might be rounding up or "ceiling", which would be somethig like t = Time.now; t - (60 - t.to_i % 60) % 60 or Time.at((Time.now.to_f / 60).ceil * 60). The the double modulo that the to_i solution needs here is a bit nasty looking, so even though it is significantly faster, here I'd prefer the ceil method. (Benchmarks appended at the very end of this post)

For those in need for speed

The tied (the differences are so insignificant that you can't really declare a winner) top two performers in the test where two to_i variants that use slightly different combination of operations and then convert integer back to Time object. If your in a hurry these are the ones you should use:

Time.at((Time.now.to_i / 60) * 60) 
t = Time.now.to_i; Time.at(t - (t % 60))

Setup for rounding up / ceil benchmarks

Benchmark.bmbm do |x|
  x.report("to_f, /, ceil, * and Time.at") { 1_000_000.times { Time.at((Time.now.to_f / 60).ceil * 60) } }
  x.report("to_i, %, -, %, + and Time.at") { 1_000_000.times { t = Time.now; t + (60 - t.to_i % 60) % 60 } }
end

Results for rounding up / ceil benchmarks

Rehearsal ----------------------------------------------------------------
to_f, /, ceil, * and Time.at   4.410000   0.040000   4.450000 (  4.446320)
to_i, %, -, %, + and Time.at   3.910000   0.020000   3.930000 (  3.939048)
------------------------------------------------------- total: 8.380000sec

                                   user     system      total        real
to_f, /, ceil, * and Time.at   4.420000   0.030000   4.450000 (  4.454173)
to_i, %, -, %, + and Time.at   3.860000   0.010000   3.870000 (  3.884866)
Timo
  • 3,335
  • 30
  • 25
6

You could do:

Time.at(t.to_i/(15*60)*(15*60))
Shalmanese
  • 5,254
  • 10
  • 29
  • 41
4

Ryan McGeary's solution didn't work for time zones that were not on the half hour. For example, Kathmandu is +5:45, so rounding to 30.minutes was getting the wrong results. This should work:

class ActiveSupport::TimeWithZone
  def floor(seconds = 60)
    return self if seconds.zero?
    Time.at(((self - self.utc_offset).to_f / seconds).floor * seconds).in_time_zone + self.utc_offset
  end

  def ceil(seconds = 60)
    return self if seconds.zero?
    Time.at(((self - self.utc_offset).to_f / seconds).ceil * seconds).in_time_zone + self.utc_offset
  end

  # returns whichever (out of #floor and #ceil) is closer to the current time
  def closest(seconds = 60)
    down, up = floor(seconds), ceil(seconds)
    ((self - down).abs > (self - up).abs) ? up : down
  end
end

And tests:

class TimeHelperTest < ActionDispatch::IntegrationTest
  test "floor" do
    t = Time.now.change(min: 14)
    assert_equal Time.now.change(min: 10), t.floor(5.minutes)
    assert_equal Time.now.change(min: 0), t.floor(30.minutes)
  end

  test "ceil" do
    t = Time.now.change(min: 16)
    assert_equal Time.now.change(min: 20), t.ceil(5.minutes)
    assert_equal Time.now.change(min: 30), t.ceil(30.minutes)
  end

  test "closest" do
    t = Time.now.change(min: 18)
    assert_equal Time.now.change(min: 20), t.closest(5.minutes)
    assert_equal Time.now.change(min: 30), t.closest(30.minutes)
    assert_equal Time.now.change(min: 0), t.closest(60.minutes)
  end

  test "works in time zones that are off the half hour" do
    Time.zone = "Kathmandu"
#2.1.0p0 :028 > Time.zone.now
# => Tue, 30 Sep 2014 06:46:12 NPT +05:45 # doing .round(30.minutes) here would give 06:45 under the old method

    t = Time.zone.now.change(min: 30)
    assert_equal Time.zone.now.change(min: 30), t.closest(30.minutes)

    t = Time.zone.now.change(min: 0)
    assert_equal Time.zone.now.change(min: 0), t.closest(30.minutes)
  end
end
Alex Ghiculescu
  • 7,522
  • 3
  • 25
  • 41
  • 1
    I use Rails 2 with ruby 1.8.7 :) I add your class to initializes folder. So `Time.zone.now.closest(5.minutes)` work fine. But `Time.now.closest(5.minutes)` gives me `NoMethodError: undefined method **closest** for Thu Jan 22 07:52:50 -0800 2015:Time` – denys281 Jan 22 '15 at 15:53
  • Where should this code go (folder and filename) in rails conventions? – bonafernando Dec 03 '19 at 13:57
4
# this is an extension of Ryan McGeary's solution, specifically for Rails.
# Note the use of utc, which is necessary to keep Rails time zone stuff happy.
# put this in config/initializers/time_extensions

require 'rubygems'
require 'active_support'

module TimeExtensions
  %w[ round floor ceil ].each do |_method|
    define_method _method do |*args|
      seconds = args.first || 60
      Time.at((self.to_f / seconds).send(_method) * seconds).utc
    end
  end
end

Time.send :include, TimeExtensions
David Lowenfels
  • 1,000
  • 6
  • 5
  • Clean solution. Do you know why the `round` method is ignored? I am running Rails 3.2.12 and Ruby 2.0. If I add the method directly to the Time class, it works. ````class Time %w[ round floor ceil ].each do |_method| define_method _method do |*args| seconds = args.first || 60 Time.at((self.to_f / seconds).send(_method) * seconds).utc end end end # No send ```` – scarver2 Feb 28 '13 at 16:41
3

Chuck's answer, while elegant, will run you into trouble if you try to compare values derived in this way; the usecs are not zeroed out.

Shalmanese' answer takes care of that, or Chuck's can be modified as:

t = Time.new
truncated_t = Time.at(t.to_i - t.sec - t.min % 15 * 60)
denishaskin
  • 3,305
  • 3
  • 24
  • 33
1

Your current evaluation using

min / 15 * 15 

is only truncating the min, so

15 => 15
16 => 15
..
29 => 15
30 => 30 

Which is not 'rounding'.

You can approximate rounding in a bad-way with

(( min + 7.5 ) / 15).to_i * 15 

Or, using internals:

( min.to_f / 15 ).round * 15
Kent Fredric
  • 56,416
  • 14
  • 107
  • 150
0

If you're using ActiveSupport, then you can use some of its time helpers.

t = Time.now
t.at_beginning_of_hour + (t.min - t.min % 15).minutes
Hosam Aly
  • 41,555
  • 36
  • 141
  • 182