[time] Use O(1) closed-form leap year math for epoch-to-year conversion (#15368)

This commit is contained in:
J. Nick Koston
2026-04-02 09:19:47 -10:00
committed by GitHub
parent e7e590b36f
commit da09e1e1ce
2 changed files with 144 additions and 28 deletions
+35 -28
View File
@@ -34,25 +34,43 @@ bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year
// Get days in year (avoids duplicate is_leap_year calls)
static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; }
// Convert days since epoch to year, updating days to remainder
static int __attribute__((noinline)) days_to_year(int64_t &days) {
int year = 1970;
int diy;
while (days >= (diy = days_in_year(year)) && year < 2200) {
days -= diy;
// Count leap years in [1, year] (i.e. up to and including year)
static constexpr int count_leap_years_up_to(int year) { return year / 4 - year / 100 + year / 400; }
constexpr int EPOCH_YEAR = 1970;
constexpr int LEAP_YEARS_BEFORE_EPOCH = count_leap_years_up_to(EPOCH_YEAR - 1);
constexpr int DAYS_PER_YEAR = 365;
constexpr int SECONDS_PER_DAY = 86400;
// Days from epoch (Jan 1 1970) to Jan 1 of given year — O(1)
static inline int64_t days_to_year_start(int year) {
return static_cast<int64_t>(DAYS_PER_YEAR) * (year - EPOCH_YEAR) +
(count_leap_years_up_to(year - 1) - LEAP_YEARS_BEFORE_EPOCH);
}
// Convert days since epoch to year, updating days to day-of-year remainder.
// The initial estimate from days/365 can overshoot by multiple years for
// far-future dates (e.g., year 5000+) due to accumulated leap days,
// so we use loops rather than single-step correction.
static int days_to_year(int64_t &days) {
int year = static_cast<int>(EPOCH_YEAR + days / DAYS_PER_YEAR);
int64_t year_start = days_to_year_start(year);
while (days < year_start) {
year--;
year_start = days_to_year_start(year);
}
while (days >= year_start + days_in_year(year)) {
year_start += days_in_year(year);
year++;
}
while (days < 0 && year > 1900) {
year--;
days += days_in_year(year);
}
days -= year_start;
return year;
}
// Extract just the year from a UTC epoch
// Extract just the year from a UTC epoch — O(1)
static int epoch_to_year(time_t epoch) {
int64_t days = epoch / 86400;
if (epoch < 0 && epoch % 86400 != 0)
int64_t days = epoch / SECONDS_PER_DAY;
if (epoch < 0 && epoch % SECONDS_PER_DAY != 0)
days--;
return days_to_year(days);
}
@@ -87,11 +105,11 @@ int __attribute__((noinline)) day_of_week(int year, int month, int day) {
void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) {
// Days since epoch
int64_t days = epoch / 86400;
int32_t remaining_secs = epoch % 86400;
int64_t days = epoch / SECONDS_PER_DAY;
int32_t remaining_secs = epoch % SECONDS_PER_DAY;
if (remaining_secs < 0) {
days--;
remaining_secs += 86400;
remaining_secs += SECONDS_PER_DAY;
}
out_tm->tm_sec = remaining_secs % 60;
@@ -280,17 +298,6 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i
return days;
}
// Calculate days from epoch to Jan 1 of given year (for DST transition calculations)
// Only supports years >= 1970. Timezone is either compiled in from YAML or set by
// Home Assistant, so pre-1970 dates are not a concern.
static int64_t __attribute__((noinline)) days_to_year_start(int year) {
int64_t days = 0;
for (int y = 1970; y < year; y++) {
days += days_in_year(y);
}
return days;
}
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
int month, day;
@@ -339,7 +346,7 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul
int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day);
// Convert to epoch and add transition time and base offset
return days * 86400 + rule.time_seconds + base_offset_seconds;
return days * SECONDS_PER_DAY + rule.time_seconds + base_offset_seconds;
}
} // namespace internal
+109
View File
@@ -758,6 +758,115 @@ TEST(PosixTzParser, EpochToLocalDstTransition) {
EXPECT_EQ(local.tm_isdst, 1);
}
// ============================================================================
// Leap year edge cases for closed-form year arithmetic
// ============================================================================
TEST(PosixTzParser, EpochToLocalLeapYear2000) {
// 2000 is a leap year (divisible by 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
// Feb 29, 2000 12:00:00 UTC
time_t epoch = make_utc(2000, 2, 29, 12);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 100); // 2000
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 29);
EXPECT_EQ(local.tm_hour, 12);
}
TEST(PosixTzParser, EpochToLocalNonLeapYear2100) {
// 2100 is NOT a leap year (divisible by 100 but not 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
// Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29
time_t epoch = make_utc(2100, 3, 1);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200); // 2100
EXPECT_EQ(local.tm_mon, 2); // March
EXPECT_EQ(local.tm_mday, 1);
// Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29)
epoch = make_utc(2100, 2, 28, 23, 59, 59);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200);
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 28);
}
TEST(PosixTzParser, EpochToLocalLeapYear2400) {
// 2400 is a leap year (divisible by 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
time_t epoch = make_utc(2400, 2, 29, 6);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 500); // 2400
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 29);
EXPECT_EQ(local.tm_hour, 6);
}
TEST(PosixTzParser, EpochToLocalNewYearBoundaries) {
// Test year boundary — last second of 2099 and first second of 2100
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
struct tm local;
// Dec 31, 2099 23:59:59 UTC
time_t epoch = make_utc(2099, 12, 31, 23, 59, 59);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 199); // 2099
EXPECT_EQ(local.tm_mon, 11); // December
EXPECT_EQ(local.tm_mday, 31);
// Jan 1, 2100 00:00:00 UTC
epoch = make_utc(2100, 1, 1);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200); // 2100
EXPECT_EQ(local.tm_mon, 0); // January
EXPECT_EQ(local.tm_mday, 1);
}
TEST(PosixTzParser, EpochToLocalDstAcrossCenturyBoundary) {
// DST transition in year 2100 (non-leap) with US Eastern rules
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz));
// July 4, 2100 16:00 UTC = 12:00 EDT
time_t epoch = make_utc(2100, 7, 4, 16);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_hour, 12);
EXPECT_EQ(local.tm_isdst, 1);
// Jan 15, 2100 10:00 UTC = 05:00 EST
epoch = make_utc(2100, 1, 15, 10);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_hour, 5);
EXPECT_EQ(local.tm_isdst, 0);
}
TEST(PosixTzParser, EpochToLocalFarFutureYear5000) {
// Year 5000 — days/365 estimate overshoots by ~2 years due to leap days,
// requiring multiple correction steps in days_to_year.
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
time_t epoch = make_utc(5000, 6, 15, 12);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 3100); // 5000
EXPECT_EQ(local.tm_mon, 5); // June
EXPECT_EQ(local.tm_mday, 15);
EXPECT_EQ(local.tm_hour, 12);
}
// ============================================================================
// Verification against libc
// ============================================================================