mirror of
https://github.com/esphome/esphome.git
synced 2026-05-30 23:54:04 +08:00
[time] Use O(1) closed-form leap year math for epoch-to-year conversion (#15368)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user