避免日期操作中舍入陷阱的最佳实践

best practices for avoiding roundoff gotchas in date manipulation

我正在做一些 date/time 操作,并且在转换 date -> time -> date 时遇到可解释但令人不快的往返问题。我通过在适当的点四舍五入暂时解决了这个问题,但我想知道是否有更清晰的日期处理最佳实践。我混合使用了 base-R 和 lubridate 函数。

tl;dr 是否有一种好的、简单的方法将十进制日期 (YYYY.fff) 转换为 Date class (和返回)而不经过 POSIXt 并导致舍入(和潜在的时区)并发症??

从 1918 年的几天开始,作为单独的 year/month/day 列(不是我的问题的关键部分,但它恰好是我的管道开始的地方):

library(lubridate)
dd <- data.frame(year=1918,month=9,day=1:12)

转换 year/month/day -> 日期 -> 时间:

dd <- transform(dd,
                time=decimal_date(make_date(year, month, day)))

由于舍入,结果时间向量中的连续差异不完全为 1:这是可以理解的,但会导致问题。

table(diff(dd$time)*365)
## 0.999999999985448  1.00000000006844 
##                 9                 2 

现在假设我转换回一个日期:日期稍微早于或晚于午夜(在任一方向上相差 <1 秒):

d2 <- lubridate::date_decimal(dd$time)
#  [1] "1918-09-01 00:00:00 UTC" "1918-09-02 00:00:00 UTC"
#  [3] "1918-09-03 00:00:00 UTC" "1918-09-03 23:59:59 UTC"
#  [5] "1918-09-04 23:59:59 UTC" "1918-09-05 23:59:59 UTC"
#  [7] "1918-09-07 00:00:00 UTC" "1918-09-08 00:00:00 UTC"
#  [9] "1918-09-09 00:00:00 UTC" "1918-09-09 23:59:59 UTC"
# [11] "1918-09-10 23:59:59 UTC" "1918-09-12 00:00:00 UTC"

如果我现在想要日期(而不是 POSIXct 对象),我可以使用 as.Date(),但令我沮丧的是 as.Date() 截断而不是舍入 ...

tt <- as.Date(d2)
## [1] "1918-09-01" "1918-09-02" "1918-09-03" "1918-09-03" "1918-09-04"
## [6] "1918-09-05" "1918-09-07" "1918-09-08" "1918-09-09" "1918-09-09"
##[11] "1918-09-10" "1918-09-12"

所以差异现在是 0/1/2 天:

table(diff(tt))
# 0 1 2 
# 2 7 2 

我可以通过先四舍五入来解决这个问题:

table(diff(as.Date(round(d2))))
## 1 
## 11

但我想知道是否有更好的方法(例如,将 POSIXct 排除在我的管道之外并保留日期 ...

正如 Grothendieck 和 Petzoldt this R-help desk article from 2004 所建议的那样:

When considering which class to use, always choose the least complex class that will support the application. That is, use Date if possible, otherwise use chron and otherwise use the POSIX classes. Such a strategy will greatly reduce the potential for error and increase the reliability of your application.

本文中广泛的 table 展示了如何在 DatechronPOSIXct 之间进行转换,但不包括十进制时间作为其中之一候选人 ...

lubridate::decimal_date() 返回 numeric。如果我理解正确的话,问题是如何将 numeric 转换为 Date 并使其适当地四舍五入而不通过 POSIXct.

as.Date(1L, origin = '1970-01-01') 告诉我们,我们可以提供 as.Date 自某个指定来源以来的天数,并立即转换为 Date 类型。知道这一点,我们可以完全跳过年份部分并将其设置为原点。然后我们可以将十进制日期转换为天数:

as.Date((dd$time-trunc(dd$time)) * 365, origin = "1918-01-01")

所以,像这样的函数可能会成功(至少在没有闰日的年份):

date_decimal2 <- function(decimal_date) {
  years <- trunc(decimal_date)
  origins <- paste0(years, "-01-01")
  # c.f. 
  do.call(c, mapply(as.Date.numeric, x = (decimal_date-years) * 365, origin = origins, SIMPLIFY = FALSE))
}

旁注:我承认我在尝试将 origin 与 1970 年前的日期进行交易时遇到了一些困难。我发现原点偏离目标日期越远,结果就越奇怪(而且不是以闰日似乎很容易解释的方式)。由于 origin 是灵活的,我决定将其定位在目标值之上。对于闰日、秒,以及时间为我们准备的任何其他古怪事物,由你自己承担。 =)

似乎最好尽可能避免从十进制时间转换回来。

从日期转换为十进制日期时,还需要考虑时间。由于 Date 没有与之关联的特定时间,因此 decimal_date 固有地假定它是 00:00:00.

但是,如果我们只关心日期(而不是时间),我们可以假设时间是任何东西。可以说,一天的中午 (12:00:00) 和一天的开始 (00:00:00) 一样好。这将使转换回 Date 更可靠,因为我们不在午夜标记并且关闭几秒钟不会影响输出。其中一种方法是将 12*60*60/(365*24*60*60) 添加到 dd$time

dd$time2 = dd$time + 12*60*60/(365*24*60*60)
data.frame(dd[1:3],
           "00:00:00" = as.Date(date_decimal(dd$time)),
           "12:00:00" = as.Date(date_decimal(dd$time2)),
           check.names = FALSE)
#   year month day        00:00:00        12:00:00
#1  1918     9   1      1918-09-01      1918-09-01
#2  1918     9   2      1918-09-02      1918-09-02
#3  1918     9   3      1918-09-03      1918-09-03
#4  1918     9   4      1918-09-03      1918-09-04
#5  1918     9   5      1918-09-04      1918-09-05
#6  1918     9   6      1918-09-05      1918-09-06
#7  1918     9   7      1918-09-07      1918-09-07
#8  1918     9   8      1918-09-08      1918-09-08
#9  1918     9   9      1918-09-09      1918-09-09
#10 1918     9  10      1918-09-09      1918-09-10
#11 1918     9  11      1918-09-10      1918-09-11
#12 1918     9  12      1918-09-12      1918-09-12

It should be noted, however, that the value of decimal time obtained in this way will be different.