diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index 76301db..40f2d03 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -1,7 +1,6 @@ import operator import re import warnings -from bisect import bisect_left from collections import namedtuple from datetime import date from datetime import datetime as dt @@ -134,16 +133,6 @@ _FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10] -# Cumulative days before the start of each Gregorian month (index 0 unused), -# for non-leap years. In to_jalali, February of a leap year is compensated -# arithmetically, so a single table suffices there. -_GREGORIAN_DAYS_BEFORE_MONTH = (0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334) - -# Cumulative days at the end of each Gregorian month, used to map a day-of-year -# back to (month, day) via binary search. -_GREGORIAN_CUM_DAYS = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365) -_GREGORIAN_CUM_DAYS_LEAP = (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366) - # List of years that are exceptions to the 33-year leap year rule # fmt: off NON_LEAP_CORRECTION_SET = frozenset( @@ -161,6 +150,14 @@ MIN_NON_LEAP_CORRECTION = 1502 +# Offset between a Jalali ordinal (1 == Farvardin 1 of year 1) and the proleptic +# Gregorian ordinal of the same day (0622-03-21). +_JDATE_ORDINAL_OFFSET = 226894 + +# Number of leap years the 33-year rule places in the first r years of a cycle +# (index 0..32), used to count leap days in closed form. Same rule as is_leap. +_CYCLE_LEAP_PREFIX = tuple(sum((25 * k + 11) % 33 < 8 for k in range(1, r + 1)) for r in range(33)) + def _is_ascii_digit(c: str) -> bool: return c in "0123456789" @@ -416,6 +413,21 @@ def days_before_month(month: int) -> int: return _MONTH_COUNT[month][2] + @staticmethod + def _days_before_year(year: int) -> int: + """ + Days from the Jalali epoch (Farvardin 1 of year 1) to the start of ``year``, + counting leap days consistently with is_leap. + """ + prev = year - 1 + leaps = (prev // 33) * 8 + _CYCLE_LEAP_PREFIX[prev % 33] + # A correction year is a 33-rule leap whose leap day is deferred to the + # next year, so it does not add a day until that following year. + if prev in NON_LEAP_CORRECTION_SET: + leaps -= 1 + + return 365 * prev + leaps + @classmethod def to_jalali(cls, year, month=None, day=None): """ @@ -444,40 +456,21 @@ def to_jalali(cls, year, month=None, day=None): JalaliDate(1400, 1, 1) """ if month is None and isinstance(year, date): - year, month, day = year.year, year.month, year.day - - # Shift the epoch so the arithmetic below operates on small positive numbers. - if year <= 1600: - jalali_year = 0 - year -= 621 + g = year else: - jalali_year = 979 - year -= 1600 - - # Past February, count the leap day of the current Gregorian year. - leap_adjusted_year = year + 1 if month > 2 else year - - # Days elapsed since the Jalali epoch (proleptic Gregorian leap rules). - days = ( - 365 * year - + (leap_adjusted_year + 3) // 4 - - (leap_adjusted_year + 99) // 100 - + (leap_adjusted_year + 399) // 400 - - 80 - + day - + _GREGORIAN_DAYS_BEFORE_MONTH[month] - ) + g = date(year, month, day) - # Reduce by whole Jalali cycles: 12053 days = 33 years, 1461 days = 4 years. - jalali_year += 33 * (days // 12053) - days %= 12053 - jalali_year += 4 * (days // 1461) - days %= 1461 + # Day number since the Jalali epoch (Farvardin 1 of year 1 == 0622-03-21). + n = g.toordinal() - _JDATE_ORDINAL_OFFSET - # The remainder covers up to 4 years; the first may be a leap year (366 days). - if days > 365: - jalali_year += (days - 1) // 365 - days = (days - 1) % 365 + # No Jalali year exceeds 366 days, so this is a lower bound at most a few + # years short; step up to the exact year. The year lengths come from + # is_leap, so the result always satisfies days_in_month. + jalali_year = max((n - 1) // 366 + 1, MINYEAR) + while cls._days_before_year(jalali_year + 1) < n: + jalali_year += 1 + + days = n - cls._days_before_year(jalali_year) - 1 # The first 6 Jalali months have 31 days, the remaining 6 have 30. if days < 186: @@ -506,48 +499,7 @@ def to_gregorian(self) -> date: >>> print(g_date) 2021-03-21 """ - month = self._month - year = self._year + 1595 - - # Days elapsed since the Gregorian epoch used by this algorithm, - # counting Jalali years (33-year cycle with 8 leap years) and months - # (the first 6 months have 31 days, the remaining 6 have 30). - days = -355668 + 365 * year + (year // 33) * 8 + ((year % 33) + 3) // 4 + self._day - if month < 7: - days += (month - 1) * 31 - else: - days += (month - 7) * 30 + 186 - - # Reduce by whole Gregorian cycles: 146097 days = 400 years, - # 36524 days = 100 years, 1461 days = 4 years. - gregorian_year = 400 * (days // 146097) - days %= 146097 - - if days > 36524: - days -= 1 - gregorian_year += 100 * (days // 36524) - days %= 36524 - if days >= 365: - days += 1 - - gregorian_year += 4 * (days // 1461) - days %= 1461 - if days > 365: - gregorian_year += (days - 1) // 365 - days = (days - 1) % 365 - - day_of_year = days + 1 - - if gregorian_year % 4 == 0 and (gregorian_year % 100 != 0 or gregorian_year % 400 == 0): - cum_days = _GREGORIAN_CUM_DAYS_LEAP - else: - cum_days = _GREGORIAN_CUM_DAYS - - # Locate the month whose cumulative day count first reaches day_of_year. - gregorian_month = bisect_left(cum_days, day_of_year) - gregorian_day = day_of_year - cum_days[gregorian_month - 1] - - return date(gregorian_year, gregorian_month, gregorian_day) + return date.fromordinal(self.toordinal() + _JDATE_ORDINAL_OFFSET) @classmethod def today(cls): @@ -611,11 +563,11 @@ def isoformat(self) -> str: __str__ = isoformat def toordinal(self) -> int: - return self.to_gregorian().toordinal() - 226894 + return self._days_before_year(self._year) + self.days_before_month(self._month) + self._day @classmethod def fromordinal(cls, n: int): - return cls(date.fromordinal(n + 226894)) + return cls(date.fromordinal(n + _JDATE_ORDINAL_OFFSET)) @classmethod def fromisocalendar(cls, year, week, day): diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 3f755ee..0342691 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -101,7 +101,7 @@ (1497, 1, 1, 2118, 3, 21), (1498, 1, 1, 2119, 3, 21), (1500, 1, 1, 2121, 3, 21), - (1503, 1, 1, 2124, 3, 21), + (1503, 1, 1, 2124, 3, 20), (1505, 1, 1, 2126, 3, 21), # Gregorian century leap-year boundaries (1278, 12, 9, 1900, 2, 28), @@ -666,6 +666,41 @@ def test_round_trip_gregorian_windows(self): previous_jalali = jdate gdate += one_day + def test_conversion_honors_leap_correction(self): + # Regression for #48: to_gregorian/to_jalali must use is_leap's leap-year + # corrections. The uncorrected 33-year cycle made to_jalali(date(2124, 3, 20)) + # raise "day must be in 1..29" and JalaliDate(1503, 12, 30) alias to 1504-01-01. + self.assertEqual(JalaliDate.to_jalali(date(2124, 3, 20)), JalaliDate(1503, 1, 1)) + self.assertEqual(JalaliDate(1503, 1, 1).to_gregorian(), date(2124, 3, 20)) + + self.assertFalse(JalaliDate.is_leap(1502)) + self.assertEqual(JalaliDate.to_jalali(date(2124, 3, 19)), JalaliDate(1502, 12, 29)) + + self.assertTrue(JalaliDate.is_leap(1503)) + esfand_30 = JalaliDate(1503, 12, 30) + self.assertEqual(JalaliDate.to_jalali(esfand_30.to_gregorian()), esfand_30) + + def test_year_length_matches_is_leap(self): + # The conversion must agree with is_leap on every year's length, across the + # whole leap-correction range (#48). + for year in range(MINYEAR, 3001): + length = (JalaliDate(year + 1, 1, 1).to_gregorian() - JalaliDate(year, 1, 1).to_gregorian()).days + self.assertEqual(length, 366 if JalaliDate.is_leap(year) else 365) + self.assertEqual(JalaliDate.days_in_month(12, year), 30 if JalaliDate.is_leap(year) else 29) + + def test_round_trip_correction_years(self): + # Round-trip every day of the years around the first leap corrections (#48). + one_day = timedelta(days=1) + for year in range(1500, 1670): + previous_gregorian = JalaliDate(year, 1, 1).to_gregorian() - one_day + for month in range(1, 13): + for day in range(1, JalaliDate.days_in_month(month, year) + 1): + jdate = JalaliDate(year, month, day) + gdate = jdate.to_gregorian() + self.assertEqual(gdate, previous_gregorian + one_day) + self.assertEqual(JalaliDate.to_jalali(gdate), jdate) + previous_gregorian = gdate + def test_string_representation(self): self.assertEqual(str(JalaliDate(1403, 4, 7)), "1403-04-07") self.assertEqual(repr(JalaliDate(1403, 4, 7)), "JalaliDate(1403, 4, 7, Panjshanbeh)")