From 076e8740b9541707cd841ddb01fba807af24bd14 Mon Sep 17 00:00:00 2001 From: Abtin Okhovat <76223666+abtinokhovat@users.noreply.github.com> Date: Sat, 17 Aug 2024 17:00:29 +0330 Subject: [PATCH] fixes #28 where the New() function for creating the time struct did not account for leap year logic. --- conversion.go | 226 ++++++++++++++++++++++++++++++++++++++++ conversion_test.go | 251 +++++++++++++++++++++++++++++++++++++++++++++ ptime.go | 90 ++++------------ 3 files changed, 496 insertions(+), 71 deletions(-) create mode 100644 conversion.go create mode 100644 conversion_test.go diff --git a/conversion.go b/conversion.go new file mode 100644 index 0000000..37d0ebc --- /dev/null +++ b/conversion.go @@ -0,0 +1,226 @@ +package ptime + +// (October 15, 1582) +const gregorianReformJulianDay = 2299160 + +// isAfterGregorianReform checks if the testDate is after the Gregorian calendar reform (October 15, 1582). +func isAfterGregorianReform(year, month, day int) bool { + return year > 1582 || (year == 1582 && month > 10) || (year == 1582 && month == 10 && day > 14) +} + +// convertGregorianPostReformToJDN calculates the Julian Day Number (JDN) for dates after the Gregorian reform. +// This function is based on the standard algorithm for converting a Gregorian calendar testDate into a Julian Day Number. +// The Gregorian reform was implemented on October 15, 1582, which corrected the drift of the Julian calendar by modifying +// leap year rules and adjusting the calendar by 10 days. +// +// The function uses several components to calculate the JDN: +// - adjustedYear: The year is adjusted to accommodate the shift caused by the Gregorian reform, adding 4800 and +// adjusting the month for the year calculation. +// - leapYearFactor: This factor accounts for the leap years by multiplying the adjusted year by 1461 and dividing by 4. +// - adjustedMonth: The month is adjusted to align with the calendar calculations, considering the calendar's +// month structure. +// - monthFactor: The adjusted month is multiplied by 367 and divided by 12 to align the month correctly. +// - centuryFactor: A century correction factor is calculated to account for the Gregorian reform's century rules. +// +// Finally, these components are combined with the day of the month (gd) and a constant offset (32075) to compute the JDN. +// https://aa.usno.navy.mil/faq/JD_formula +func convertGregorianPostReformToJDN(year, month, day int) int { + const ( + // The specific value 1461 is derived from the fact that there are 365.25 days in a year on average, + // and 1461 represents the number of days in a 4-year cycle (365.25 * 4). + // This value is chosen to align with the cycles in the Gregorian calendar, + // especially the leap year cycle, and it's commonly used in algorithms that involve date calculations. + daysInFourYearCycle = 1461 + yearOffset = 4800 // Offset to adjust the year for calculations + centuryAdjustmentOffset = 4900 // Offset to adjust the century for calculations + monthCycleFactor = 367 // Multiplier used in the month cycle calculation + baseDayAdjustment = 32075 // Adjustment factor for the base day calculation + ) + + adjustedYear := year + yearOffset + ((month - 14) / 12) + leapYearFactor := (daysInFourYearCycle * adjustedYear) / 4 + + adjustedMonth := month - 2 - 12*((month-14)/12) + monthFactor := (monthCycleFactor * adjustedMonth) / 12 + + centuryFactor := (3 * ((year + centuryAdjustmentOffset + ((month - 14) / 12)) / 100)) / 4 + + return leapYearFactor + monthFactor - centuryFactor + day - baseDayAdjustment +} + +// convertGregorianPreReformToJDN calculates the Julian Day Number (JDN) for dates before the Gregorian reform. +// Before the Gregorian calendar was introduced in 1582, the Julian calendar was used, which had a simpler rule for leap years +// and no century corrections. This function uses the Julian calendar's rules to calculate the JDN. +// +// The function uses several components to calculate the JDN: +// - adjustedYear: The year is adjusted to accommodate the Julian calendar's structure, adding 5001 and adjusting the month. +// - leapYearFactor: This factor accounts for the leap years under the Julian rules, multiplying the adjusted year by 7 +// and dividing by 4. +// - monthFactor: The month is multiplied by 275 and divided by 9 to align the month correctly. +// +// These components are combined with the day of the month (gd) and a constant offset (1729777) to compute the JDN for dates +// before the Gregorian reform. +// https://aa.usno.navy.mil/faq/JD_formula +func convertGregorianPreReformToJDN(year, month, day int) int { + adjustedYear := year + 5001 + (month-9)/7 + leapYearFactor := (7 * adjustedYear) / 4 + + monthFactor := (275 * month) / 9 + + return 367*year - leapYearFactor + monthFactor + day + 1729777 +} + +// convertJDNToGregorianPostReform converts a Julian Day Number (JDN) to the corresponding +// Gregorian testDate for dates after the Gregorian calendar reform (after October 15, 1582). +// https://aa.usno.navy.mil/faq/JD_formula +func convertJDNToGregorianPostReform(jdn int) (year, month, day int) { + const ( + daysInFourYearCycle = 1461 + // The specific value 2447 is chosen based on the characteristics of the Gregorian calendar and its various cycles. + // It plays a role in the algorithm to determine the month and day components of the Gregorian date during the conversion process from Julian Day. + daysInMonthMultiplier = 2447 + // Offset used to adjust Julian Day + julianDayOffset = 68569 + // The specific value 1461001 is derived from the fact that there are 365.25 days in a year on average, + // and 1461001 represents the number of days in a 4000-year cycle (365.25 * 4000). + // This value is chosen to align with the cycles in the Gregorian calendar, + // facilitating the conversion between Julian Day and Gregorian Date. + julianDay4000YearCycleDayOffset = 1461001 // 365.25 * 4000 + // The specific value 146097 is derived from the fact that there are 365.25 days in a year on average, + // and 146097 represents the number of days in a 400-year cycle (365.25 * 400). + // This value is commonly used in algorithms that involve date calculations, especially when dealing with leap years. + julianDayOf400Years = 146097 // 365.25 * 400 + ) + + offsetJDN := jdn + julianDayOffset + + // Calculate century + century := 4 * offsetJDN / julianDayOf400Years + offsetJDN = offsetJDN - (julianDayOf400Years*century+3)/4 + + // Calculate year + yearBase := 4000 * (offsetJDN + 1) / julianDay4000YearCycleDayOffset + offsetJDN = offsetJDN - daysInFourYearCycle*yearBase/4 + 31 + + // Calculate month and day + monthFactor := 80 * offsetJDN / daysInMonthMultiplier + day = offsetJDN - daysInMonthMultiplier*monthFactor/80 + offsetJDN = monthFactor / 11 + month = monthFactor + 2 - 12*offsetJDN + year = 100*(century-49) + yearBase + offsetJDN + + return year, month, day +} + +// convertJDNToGregorianPreReform converts a Julian Day Number (JDN) to the corresponding +// Gregorian testDate for dates before the Gregorian calendar reform (before October 15, 1582). +func convertJDNToGregorianPreReform(jdn int) (year, month, day int) { + const ( + daysInFourYearCycle = 1461 + daysInMonthMultiplier = 2447 // Multiplier used for month calculation + julianDayOffset = 1402 // Offset used to adjust Julian Day for pre-Gregorian dates + ) + + offsetJDN := jdn + julianDayOffset + + // Calculate year + quadrennialCycle := (offsetJDN - 1) / daysInFourYearCycle + remainingDays := offsetJDN - daysInFourYearCycle*quadrennialCycle + yearAdjustment := (remainingDays-1)/365 - remainingDays/daysInFourYearCycle + dayOfYear := remainingDays - 365*yearAdjustment + 30 + + // Calculate month and day + monthFactor := 80 * dayOfYear / daysInMonthMultiplier + day = dayOfYear - daysInMonthMultiplier*monthFactor/80 + yearFraction := monthFactor / 11 + month = monthFactor + 2 - 12*yearFraction + year = 4*quadrennialCycle + yearAdjustment + yearFraction - 4716 + + return year, month, day +} + +// convertJDNToShamsi converts a Julian Day Number (JDN) to the Shamsi (Solar Hijri) calendar testDate. +// The conversion is based on the offset between the Julian calendar and the Shamsi calendar. +// The calculation is performed as follows: +// - The JDN is adjusted by subtracting a constant offset to align it with the Shamsi calendar. +// - The resulting value is used to calculate the year by accounting for cycles of 33 years and leap years. +// - The month and day are then calculated based on the remaining days within the year. +// +// Parameters: +// - jdn: The Julian Day Number to be converted. +// +// Returns: +// - year: The calculated year in the Shamsi calendar. +// - month: The calculated month in the Shamsi calendar. +// - day: The calculated day in the Shamsi calendar. +func convertJDNToShamsi(jdn int) (year, month, day int) { + const ( + julianDayToShamsiOffset = 1365393 + cyclesOf33YearsCount = 12053 // 33 * 364.24 + daysInFourYearCycle = 1461 + middleDayInYear = 186 // 6 * 31 + ) + + // Align the Julian Day Number with the Shamsi calendar + daysSinceStartOfShamsi := jdn - julianDayToShamsiOffset + + // Calculate the Shamsi year + cyclesOf33Years := daysSinceStartOfShamsi / cyclesOf33YearsCount + year = -1595 + 33*cyclesOf33Years + remainingDays := daysSinceStartOfShamsi % cyclesOf33YearsCount + + cyclesOf4Years := remainingDays / daysInFourYearCycle + year += 4 * cyclesOf4Years + remainingDays %= daysInFourYearCycle + + // Adjust for remaining days within the current cycle + if remainingDays > 365 { + year += (remainingDays - 1) / 365 + remainingDays = (remainingDays - 1) % 365 + } + + // Determine the Shamsi month and day + if remainingDays < middleDayInYear { + month = 1 + remainingDays/31 + day = 1 + remainingDays%31 + } else { + month = 7 + (remainingDays-middleDayInYear)/30 + day = 1 + (remainingDays-middleDayInYear)%30 + } + + return year, month, day +} + +// convertShamsiToJDN converts a Shamsi (Solar Hijri) calendar testDate to the corresponding Julian Day Number (JDN). +// The calculation takes into account the specific offset and adjustments needed for leap years in the Shamsi calendar. +func convertShamsiToJDN(year, month, day int) int { + const ( + shamsiToJulianOffset = 1365392 + leapYearCycle = 33 + leapYearContribution = 8 + middleDayInYear = 186 // 6 * 31 + daysInFirstSixMonths = 31 // Months 1-6: each month has 31 days + daysInNextSixMonths = 30 // Months 7-12: each month has 30 days + ) + + // Adjust the Shamsi year for the calculation + adjustedShamsiYear := year + 1595 + + // Calculate the number of leap years that have occurred up to the given year + leapYearContributionCount := (adjustedShamsiYear/leapYearCycle)*leapYearContribution + + ((adjustedShamsiYear%leapYearCycle + 3) / 4) + + // Determine the day of the year within the Shamsi calendar + var dayOfYear int + if month < 7 { + dayOfYear = (month - 1) * daysInFirstSixMonths + } else { + dayOfYear = (month-7)*daysInNextSixMonths + middleDayInYear + } + + // Calculate the Julian Day Number (JDN) + jdn := shamsiToJulianOffset + 365*adjustedShamsiYear + + leapYearContributionCount + dayOfYear + day + + return jdn +} diff --git a/conversion_test.go b/conversion_test.go new file mode 100644 index 0000000..008b7a0 --- /dev/null +++ b/conversion_test.go @@ -0,0 +1,251 @@ +package ptime + +import ( + "fmt" + "testing" +) + +func TestCalculateJDNToGregorian(t *testing.T) { + for _, tc := range julianToGregorianMapping { + t.Run(fmt.Sprintf("%d/%d/%d", tc.gregorianDate.year, tc.gregorianDate.month, tc.gregorianDate.day), func(t *testing.T) { + // Calculate JDN from the Gregorian testDate + calculatedJDN := convertGregorianPostReformToJDN(tc.gregorianDate.year, tc.gregorianDate.month, tc.gregorianDate.day) + + // Compare the calculated JDN with the expected JDN + if calculatedJDN != tc.julianDay { + t.Errorf("Test failed for testDate %d-%d-%d: expected JDN %d, got %d\n", + tc.gregorianDate.year, tc.gregorianDate.month, tc.gregorianDate.day, tc.julianDay, calculatedJDN) + } + }) + + } +} + +func TestCalculateGregorianToJDN(t *testing.T) { + for _, tc := range julianToGregorianMapping { + t.Run(fmt.Sprintf("%d/%d/%d", tc.gregorianDate.year, tc.gregorianDate.month, tc.gregorianDate.day), func(t *testing.T) { + // calculate shamsi testDate by jdn + year, month, day := convertJDNToGregorianPostReform(tc.julianDay) + + if year != tc.gregorianDate.year { + t.Errorf("Test failed for testDate %d: expected Year %d, got %d\n", tc.julianDay, tc.gregorianDate.year, year) + } + + if month != tc.gregorianDate.month { + t.Errorf("Test failed for testDate %d: expected Month %d, got %d\n", tc.julianDay, tc.gregorianDate.month, month) + } + + if day != tc.gregorianDate.day { + t.Errorf("Test failed for testDate %d: expected Day %d, got %d\n", tc.julianDay, tc.gregorianDate.day, day) + } + }) + + } +} + +func TestCalculateJDNToShamsi(t *testing.T) { + for _, tc := range julianToShamsiMapping { + t.Run(fmt.Sprintf("%d/%d/%d", tc.shamsiDate.year, tc.shamsiDate.month, tc.shamsiDate.day), func(t *testing.T) { + // calculate shamsi testDate by jdn + year, month, day := convertJDNToShamsi(tc.julianDay) + + if year != tc.shamsiDate.year { + t.Errorf("Test failed for testDate %d: expected Year %d, got %d\n", tc.julianDay, tc.shamsiDate.year, year) + } + + if month != tc.shamsiDate.month { + t.Errorf("Test failed for testDate %d: expected Month %d, got %d\n", tc.julianDay, tc.shamsiDate.month, month) + } + + if day != tc.shamsiDate.day { + t.Errorf("Test failed for testDate %d: expected Day %d, got %d\n", tc.julianDay, tc.shamsiDate.day, day) + } + }) + + } +} + +func TestCalculateShamsiToJDN(t *testing.T) { + for _, tc := range julianToShamsiMapping { + t.Run(fmt.Sprintf("%d/%d/%d", tc.shamsiDate.year, tc.shamsiDate.month, tc.shamsiDate.day), func(t *testing.T) { + // calculate shamsi testDate by jdn + julianDay := convertShamsiToJDN(tc.shamsiDate.year, tc.shamsiDate.month, tc.shamsiDate.day) + + if julianDay != tc.julianDay { + t.Errorf("Test failed for testDate %d-%d-%d: expected JDN %d, got %d\n", + tc.shamsiDate.year, tc.shamsiDate.month, tc.shamsiDate.day, tc.julianDay, julianDay) + } + }) + } + +} + +type testDate struct { + year, month, day int +} + +// also these test cases where calculated by hand and the reference of time.ir to check for validation of them +var julianToShamsiMapping = []struct { + julianDay int + shamsiDate testDate +}{ + { + shamsiDate: testDate{year: 1403, month: 12, day: 30}, + julianDay: 2460755, + }, + { + shamsiDate: testDate{year: 1400, month: 6, day: 15}, + julianDay: 2459464, + }, + { + shamsiDate: testDate{year: 1395, month: 1, day: 1}, + julianDay: 2457468, + }, + { + shamsiDate: testDate{year: 1422, month: 12, day: 29}, + julianDay: 2467694, + }, + { + shamsiDate: testDate{year: 1388, month: 7, day: 12}, + julianDay: 2455109, + }, + { + shamsiDate: testDate{year: 1415, month: 4, day: 5}, + julianDay: 2464870, + }, + { + shamsiDate: testDate{year: 1390, month: 10, day: 20}, + julianDay: 2455937, + }, + { + shamsiDate: testDate{year: 1435, month: 2, day: 9}, + julianDay: 2472117, + }, + { + shamsiDate: testDate{year: 1377, month: 5, day: 3}, + julianDay: 2451020, + }, + { + shamsiDate: testDate{year: 1405, month: 7, day: 25}, + julianDay: 2461331, + }, + { + shamsiDate: testDate{year: 1399, month: 12, day: 29}, + julianDay: 2459293, + }, + { + shamsiDate: testDate{year: 1385, month: 8, day: 15}, + julianDay: 2454046, + }, + { + shamsiDate: testDate{year: 1418, month: 3, day: 7}, + julianDay: 2465937, + }, + { + shamsiDate: testDate{year: 1372, month: 2, day: 1}, + julianDay: 2449099, + }, + { + shamsiDate: testDate{year: 1429, month: 11, day: 12}, + julianDay: 2470204, + }, + { + shamsiDate: testDate{year: 1407, month: 6, day: 8}, + julianDay: 2462013, + }, + { + shamsiDate: testDate{year: 1393, month: 4, day: 25}, + julianDay: 2456855, + }, + { + shamsiDate: testDate{year: 1437, month: 7, day: 3}, + julianDay: 2472997, + }, + { + shamsiDate: testDate{year: 1380, month: 1, day: 1}, + julianDay: 2451990, + }, + { + shamsiDate: testDate{year: 1410, month: 9, day: 18}, + julianDay: 2463210, + }, + { + shamsiDate: testDate{year: 1398, month: 11, day: 29}, + julianDay: 2458898, + }, + { + shamsiDate: testDate{year: 1376, month: 12, day: 29}, // Leap year + julianDay: 2450893, + }, + { + shamsiDate: testDate{year: 1397, month: 6, day: 20}, + julianDay: 2458373, + }, + { + shamsiDate: testDate{year: 1414, month: 12, day: 29}, // Leap year + julianDay: 2464772, + }, + { + shamsiDate: testDate{year: 1388, month: 11, day: 10}, + julianDay: 2455227, + }, + { + shamsiDate: testDate{year: 1400, month: 1, day: 1}, // Leap year + julianDay: 2459295, + }, + { + shamsiDate: testDate{year: 1422, month: 7, day: 25}, + julianDay: 2467540, + }, + { + shamsiDate: testDate{year: 1375, month: 9, day: 8}, + julianDay: 2450416, + }, + { + shamsiDate: testDate{year: 1396, month: 2, day: 20}, // Leap year + julianDay: 2457884, + }, + { + shamsiDate: testDate{year: 1411, month: 3, day: 1}, + julianDay: 2463374, + }, + { + shamsiDate: testDate{year: 1382, month: 8, day: 20}, + julianDay: 2452955, + }, + { + shamsiDate: testDate{year: 1378, month: 11, day: 24}, + julianDay: 2451588, + }, + { + shamsiDate: testDate{year: 1348, month: 10, day: 11}, + julianDay: 2440588, + }, +} + +// the reference for these test cases are https://aa.usno.navy.mil/data/JulianDate +var julianToGregorianMapping = []struct { + julianDay int + gregorianDate testDate +}{ + { + julianDay: 2460755, // 1403/12/30 + gregorianDate: testDate{year: 2025, month: 3, day: 20}, + }, + { + julianDay: 2451588, + gregorianDate: testDate{year: 2000, month: 2, day: 13}, + }, + { + julianDay: 2440588, + gregorianDate: testDate{year: 1970, month: 1, day: 1}, + }, + { + julianDay: 2406842, + gregorianDate: testDate{year: 1877, month: 8, day: 10}, + }, + { + julianDay: 2460493, + gregorianDate: testDate{year: 2024, month: 7, day: 1}, + }, +} diff --git a/ptime.go b/ptime.go index 360ea22..ff4c86b 100644 --- a/ptime.go +++ b/ptime.go @@ -313,41 +313,28 @@ func New(t time.Time) Time { return *pt } -// Time converts Persian date to Gregorian date and returns a new instance of time.Time +// Time converts the Shamsi (Solar Hijri) testDate stored in the Time struct to the corresponding +// Gregorian testDate and returns it as a Go time.Time object. func (t Time) Time() time.Time { var year, month, day int - jdn := getJdn(t.year, int(t.month), t.day) - - if jdn > 2299160 { - l := jdn + 68569 - n := 4 * l / 146097 - l = l - (146097*n+3)/4 - i := 4000 * (l + 1) / 1461001 - l = l - 1461*i/4 + 31 - j := 80 * l / 2447 - day = l - 2447*j/80 - l = j / 11 - month = j + 2 - 12*l - year = 100*(n-49) + i + l + // Convert the Shamsi testDate to the corresponding Julian Day Number (JDN) + jdn := convertShamsiToJDN(t.year, int(t.month), t.day) + + // Convert the JDN to a Gregorian testDate + if jdn > gregorianReformJulianDay { + year, month, day = convertJDNToGregorianPostReform(jdn) } else { - j := jdn + 1402 - k := (j - 1) / 1461 - l := j - 1461*k - n := (l-1)/365 - l/1461 - i := l - 365*n + 30 - j = 80 * i / 2447 - day = i - 2447*j/80 - i = j / 11 - month = j + 2 - 12*i - year = 4*k + n + i - 4716 + year, month, day = convertJDNToGregorianPreReform(jdn) } + // Use the location stored in the Time struct, or default to the local time zone loc := t.loc if loc == nil { loc = time.Local } + // Return the corresponding time.Time object return time.Date(year, time.Month(month), day, t.hour, t.min, t.sec, t.nsec, loc) } @@ -381,7 +368,10 @@ func Now() Time { return New(time.Now()) } -// SetTime sets t to the time of ti. +// SetTime sets the time and testDate for the `Time` struct based on the input `time.Time` object. +// This function converts a Gregorian testDate (as provided by `ti`) to the Shamsi (Persian) calendar. +// It first calculates the Julian Day Number (JDN), a continuous count of days since the beginning +// of the Julian Period, and then converts this JDN to a Shamsi testDate. func (t *Time) SetTime(ti time.Time) { var year, month, day int @@ -396,37 +386,13 @@ func (t *Time) SetTime(ti time.Time) { gy, gmm, gd := ti.Date() gm := int(gmm) - if gy > 1582 || (gy == 1582 && gm > 10) || (gy == 1582 && gm == 10 && gd > 14) { - jdn = ((1461 * (gy + 4800 + ((gm - 14) / 12))) / 4) + ((367 * (gm - 2 - 12*((gm-14)/12))) / 12) - ((3 * ((gy + 4900 + ((gm - 14) / 12)) / 100)) / 4) + gd - 32075 - } else { - jdn = 367*gy - ((7 * (gy + 5001 + ((gm - 9) / 7))) / 4) + ((275 * gm) / 9) + gd + 1729777 - } - - dep := jdn - getJdn(475, 1, 1) - cyc := dep / 1029983 - rem := dep % 1029983 - - var ycyc int - if rem == 1029982 { - ycyc = 2820 - } else { - a := rem / 366 - ycyc = (2134*a+2816*(rem%366)+2815)/1028522 + a + 1 - } - - year = ycyc + 2820*cyc + 474 - if year <= 0 { - year = year - 1 - } - - var dy = float64(jdn - getJdn(year, 1, 1) + 1) - if dy <= 186 { - month = int(math.Ceil(dy / 31.0)) + if isAfterGregorianReform(gy, gm, gd) { + jdn = convertGregorianPostReformToJDN(gy, gm, gd) } else { - month = int(math.Ceil((dy - 6) / 30.0)) + jdn = convertGregorianPreReformToJDN(gy, gm, gd) } - day = jdn - getJdn(year, month, 1) + 1 + year, month, day = convertJDNToShamsi(jdn) t.year = year t.month = Month(month) @@ -1209,24 +1175,6 @@ func divider(num, den int) int { return num - ((((num + 1) / den) - 1) * den) } -func getJdn(year int, month int, day int) int { - base := year - 473 - if year >= 0 { - base-- - } - - epy := 474 + (base % 2820) - - var md int - if month <= 7 { - md = (month - 1) * 31 - } else { - md = (month-1)*30 + 6 - } - - return day + md + (epy*682-110)/2816 + (epy-1)*365 + base/2820*1029983 + 1948320 -} - func getWeekday(wd time.Weekday) Weekday { switch wd { case time.Saturday: