Skip to content

Commit c48a6c6

Browse files
committed
Improve month duration logic for non-gregorian calendars
resolves #140
1 parent c6b97ff commit c48a6c6

File tree

3 files changed

+72
-24
lines changed

3 files changed

+72
-24
lines changed

src/undate/converters/calendars/hebrew/converter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class HebrewDateConverter(BaseCalendarConverter):
2121
name: str = "Hebrew"
2222
calendar_name: str = "Anno Mundi"
2323

24+
#: earliest possible year in the Hebrew calendar is year 1, it does not go negative
25+
MIN_YEAR: int = 1
26+
# convertdate gives a month 34 for numpy max year 2.5^16, so scale it back a bit
27+
MAX_YEAR = int(2.5e12)
28+
2429
#: arbitrary known non-leap year; 4816 is a non-leap year with 353 days (minimum possible)
2530
NON_LEAP_YEAR: int = 4816
2631
#: arbitrary known leap year; 4837 is a leap year with 385 days (maximum possible)

src/undate/undate.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -475,29 +475,35 @@ def _get_date_part(self, part: str) -> Optional[str]:
475475
def possible_years(self) -> list[int] | range:
476476
"""A list or range of possible years for this date in the original calendar.
477477
Returns a list with a single year for dates with fully-known years."""
478-
if self.known_year:
479-
return [self.earliest.year]
478+
# get the initial value passed in for year in original calendar
479+
initial_year_value = self.initial_values["year"]
480+
# if integer, year is fully known and is the only possible value
481+
if isinstance(initial_year_value, int):
482+
return [initial_year_value]
480483

481-
step = 1
484+
# if year is None or string with all unknown digits, bail out
482485
if (
483-
self.is_partially_known("year")
484-
and str(self.year).replace(self.MISSING_DIGIT, "") != ""
486+
initial_year_value is None
487+
or str(self.year).replace(self.MISSING_DIGIT, "") == ""
485488
):
486-
# determine the smallest step size for the missing digit
487-
earliest_year = int(str(self.year).replace(self.MISSING_DIGIT, "0"))
488-
latest_year = int(str(self.year).replace(self.MISSING_DIGIT, "9"))
489-
missing_digit_place = len(str(self.year)) - str(self.year).rfind(
490-
self.MISSING_DIGIT
489+
# otherwise, year is fully unknown
490+
# returning range from min year to max year is not useful in any scenario!
491+
raise ValueError(
492+
"Possible years cannot be returned for completely unknown year"
491493
)
492-
# convert place to 1, 10, 100, 1000, etc.
493-
step = 10 ** (missing_digit_place - 1)
494-
return range(earliest_year, latest_year + 1, step)
495-
496-
# otherwise, year is fully unknown
497-
# returning range from min year to max year is not useful in any scenario!
498-
raise ValueError(
499-
"Possible years cannot be returned for completely unknown year"
494+
495+
# otherwise, year is partially known
496+
# determine the smallest step size for the missing digit
497+
earliest_year = int(str(self.year).replace(self.MISSING_DIGIT, "0"))
498+
latest_year = int(str(self.year).replace(self.MISSING_DIGIT, "9"))
499+
missing_digit_place = len(str(self.year)) - str(self.year).rfind(
500+
self.MISSING_DIGIT
500501
)
502+
# convert place to 1, 10, 100, 1000, etc.
503+
step = 10 ** (missing_digit_place - 1)
504+
# generate a range from earliest to latest with the appropriate step
505+
# based on the smallest missing digit
506+
return range(earliest_year, latest_year + 1, step)
501507

502508
@property
503509
def representative_years(self) -> list[int]:
@@ -540,12 +546,32 @@ def duration(self) -> Timedelta | UnDelta:
540546
# appease mypy, which says month values could be None here;
541547
# Date object allows optional month, but earliest/latest initialization
542548
# should always be day-precision dates
543-
if self.earliest.month is not None and self.latest.month is not None:
544-
for possible_month in range(self.earliest.month, self.latest.month + 1):
545-
for year in self.representative_years:
546-
possible_max_days.add(
547-
self.calendar_converter.max_day(year, possible_month)
548-
)
549+
550+
# FIXME: earliest/latest are gregorian! need to use months from the original calendar,
551+
# not converted months
552+
initial_month_value = self.initial_values["month"]
553+
# if integer, month is fully known and is the only possible value
554+
possible_months: list[int] | range
555+
if isinstance(initial_month_value, int):
556+
possible_months = [initial_month_value]
557+
elif isinstance(initial_month_value, str):
558+
# earliest possible month for missing digit
559+
earliest_month = int(
560+
initial_month_value.replace(self.MISSING_DIGIT, "0")
561+
)
562+
# latest possible month for missing digit, but no greater than
563+
# calendar max month
564+
latest_month = min(
565+
self.calendar_converter.max_month(
566+
self.calendar_converter.LEAP_YEAR
567+
),
568+
int(initial_month_value.replace(self.MISSING_DIGIT, "9")),
569+
)
570+
possible_months = range(earliest_month, latest_month + 1)
571+
572+
for month in possible_months:
573+
for year in self.representative_years:
574+
possible_max_days.add(self.calendar_converter.max_day(year, month))
549575

550576
# if precision is year but year is unknown, return an uncertain delta
551577
elif self.precision == DatePrecision.YEAR:

tests/test_undate.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@ def test_possible_years(self):
410410
):
411411
assert Undate("XXXX").possible_years
412412

413+
# non-gregorian years should return in original calendar
414+
assert Undate(401, calendar="Hebrew").possible_years == [401]
415+
413416
def test_representative_years(self):
414417
# single year is returned as is
415418
assert Undate("1991").representative_years == [1991]
@@ -453,6 +456,20 @@ def test_duration(self):
453456
leapyear_duration = Undate(2024).duration()
454457
assert leapyear_duration.days == 366
455458

459+
def test_duration_month_nongregorian(self):
460+
# known-months for non-gregorian calendars should not be uncertain
461+
assert Undate(1288, 4, calendar="Seleucid").duration().days == 29
462+
assert Undate(1548, 5, calendar="Seleucid").duration().days == 30
463+
assert Undate(4791, 11, calendar="Hebrew").duration().days == 30
464+
assert Undate(4808, 10, calendar="Hebrew").duration().days == 29
465+
assert Undate(942, 1, calendar="Islamic").duration().days == 30
466+
assert Undate(984, 8, calendar="Islamic").duration().days == 29
467+
468+
# in some cases month length may vary by year
469+
assert Undate(month=4, calendar="Seleucid").duration().days == 29
470+
assert Undate(month=8, calendar="Hebrew").duration().days == UnInt(29, 30)
471+
assert Undate(month=1, calendar="Islamic").duration().days == 30
472+
456473
def test_partiallyknown_duration(self):
457474
# day in unknown month/year
458475
# assert Undate(day=5).duration().days == 1

0 commit comments

Comments
 (0)