diff --git a/src/pyluach/gematria.py b/src/pyluach/gematria.py index 685b588..fcda93d 100644 --- a/src/pyluach/gematria.py +++ b/src/pyluach/gematria.py @@ -1,50 +1,92 @@ +"""Gematria conversion — Hebrew numerals from integers. + +This module provides functions to convert integers into their Hebrew +letter (gematria) representation, with optional geresh/gershayim +punctuation and thousands-place notation. +""" + +# Mapping of integer values to their single Hebrew letter equivalents. +# Covers the 22 standard gematria values from 1 to 400. _GEMATRIOS = { - 1: 'א', - 2: 'ב', - 3: 'ג', - 4: 'ד', - 5: 'ה', - 6: 'ו', - 7: 'ז', - 8: 'ח', - 9: 'ט', - 10: 'י', - 20: 'כ', - 30: 'ל', - 40: 'מ', - 50: 'נ', - 60: 'ס', - 70: 'ע', - 80: 'פ', - 90: 'צ', - 100: 'ק', - 200: 'ר', - 300: 'ש', - 400: 'ת' + 1: 'א', 2: 'ב', 3: 'ג', 4: 'ד', 5: 'ה', + 6: 'ו', 7: 'ז', 8: 'ח', 9: 'ט', 10: 'י', + 20: 'כ', 30: 'ל', 40: 'מ', 50: 'נ', 60: 'ס', + 70: 'ע', 80: 'פ', 90: 'צ', 100: 'ק', 200: 'ר', + 300: 'ש', 400: 'ת', } +# Special substitutions: 15 and 16 are written as טו and טז +# (not יה and יו) to avoid writing the names of God. +_SPECIAL_REPLACEMENTS = {'יה': 'טו', 'יו': 'טז'} + +_GERESH = '׳' +_GERSHAYIM = '״' +_TAV = 'ת' + + +def _apply_special_replacements(letters): + """Apply traditional gematria substitutions to avoid divine names.""" + for original, replacement in _SPECIAL_REPLACEMENTS.items(): + letters = letters.replace(original, replacement) + return letters + def _stringify_gematria(letters): - """Insert geresh or gershayim symbols into gematria.""" - length = len(letters) - if length > 1: - return f'{letters[:-1]}״{letters[-1]}' - if length == 1: - return f'{letters}׳' - return '' + """Insert geresh or gershayim symbols into gematria. + + Parameters + ---------- + letters : str + A string of Hebrew letters representing a gematria value. + + Returns + ------- + str + The input with gershayim (״) inserted before the last character + for multi-character strings, or geresh (׳) appended for single + characters. Returns an empty string for empty input. + """ + if not letters: + return '' + if len(letters) == 1: + return f'{letters}{_GERESH}' + return f'{letters[:-1]}{_GERSHAYIM}{letters[-1]}' def _get_letters(num): - """Convert numbers under 1,000 into raw letters.""" - ones = num % 10 - tens = num % 100 - ones - hundreds = num % 1000 - tens - ones - four_hundreds = ''.join(['ת' for i in range(hundreds // 400)]) - ones = _GEMATRIOS.get(ones, '') - tens = _GEMATRIOS.get(tens, '') - hundreds = _GEMATRIOS.get(hundreds % 400, '') - letters = f'{four_hundreds}{hundreds}{tens}{ones}' - return letters.replace('יה', 'טו').replace('יו', 'טז') + """Convert numbers under 1,000 into raw Hebrew letters. + + Breaks the number into hundreds, tens, and ones place values, + then maps each to its corresponding Hebrew letter. Values + of 400 or more in the hundreds place are represented using + repeated ת characters (e.g., 500 = תק, 900 = תתק). + + Parameters + ---------- + num : int + A non-negative integer less than 1000. + + Returns + ------- + str + The Hebrew letter representation with special substitutions + applied (טו for 15, טז for 16). + """ + ones_digit = num % 10 + tens_digit = (num % 100) - ones_digit + hundreds_raw = (num % 1000) - tens_digit - ones_digit + + # Hundreds >= 400 are represented as repeated ת (400) + remainder + tav_count = hundreds_raw // 400 + hundreds_remainder = hundreds_raw % 400 + + ones_letter = _GEMATRIOS.get(ones_digit, '') + tens_letter = _GEMATRIOS.get(tens_digit, '') + hundreds_letter = _GEMATRIOS.get(hundreds_remainder, '') + tav_prefix = _TAV * tav_count + + raw_letters = f'{tav_prefix}{hundreds_letter}{tens_letter}{ones_letter}' + return _apply_special_replacements(raw_letters) def _num_to_str(num, thousands=False, withgershayim=True): @@ -57,6 +99,9 @@ def _num_to_str(num, thousands=False, withgershayim=True): thousands : bool, optional True if the hebrew returned should include a letter for the thousands place ie. 'ה׳' for five thousand. + withgershayim : bool, optional + True to include geresh/gershayim punctuation marks. + Default is True. Returns ------- @@ -67,8 +112,8 @@ def _num_to_str(num, thousands=False, withgershayim=True): if withgershayim: letters = _stringify_gematria(letters) if thousands: - thousand = _get_letters(num // 1000) + thousand_letters = _get_letters(num // 1000) if withgershayim: - thousand = ''.join([thousand, '׳']) - letters = ''.join([thousand, letters]) + thousand_letters = f'{thousand_letters}{_GERESH}' + letters = f'{thousand_letters}{letters}' return letters diff --git a/src/pyluach/utils.py b/src/pyluach/utils.py index 0df82ea..1d7aba6 100644 --- a/src/pyluach/utils.py +++ b/src/pyluach/utils.py @@ -92,9 +92,13 @@ class _Days(Enum): def _is_leap(year): - if (((7*year) + 1) % 19) < 7: - return True - return False + """Return True if the Hebrew year is a leap year. + + Uses the 19-year Metonic cycle: years 3, 6, 8, 11, 14, 17, 19 + are leap years. The formula ((7*year + 1) % 19) < 7 encodes + this cycle compactly. + """ + return ((7 * year) + 1) % 19 < 7 def _elapsed_months(year): @@ -103,32 +107,64 @@ def _elapsed_months(year): @lru_cache(maxsize=10) def _elapsed_days(year): + """Return the number of days from the epoch to the start of the year. + + This implements the Hebrew calendar's dehiyyah (postponement) rules + that determine the day of week for Rosh Hashanah. + """ months_elapsed = _elapsed_months(year) - parts_elapsed = 204 + 793*(months_elapsed%1080) + parts_elapsed = 204 + 793 * (months_elapsed % 1080) hours_elapsed = ( - 5 + 12*months_elapsed + 793*(months_elapsed//1080) - + parts_elapsed//1080) - conjunction_day = 1 + 29*months_elapsed + hours_elapsed//24 - conjunction_parts = 1080 * (hours_elapsed%24) + parts_elapsed%1080 - - if ( - (conjunction_parts >= 19440) - or ( - (conjunction_day % 7 == 2) and (conjunction_parts >= 9924) - and not _is_leap(year) - ) - or ( - (conjunction_day % 7 == 1) and conjunction_parts >= 16789 - and _is_leap(year - 1) - ) - ): - alt_day = conjunction_day + 1 - else: - alt_day = conjunction_day - if alt_day % 7 in [0, 3, 5]: - alt_day += 1 - - return alt_day + 5 + 12 * months_elapsed + 793 * (months_elapsed // 1080) + + parts_elapsed // 1080 + ) + conjunction_day = 1 + 29 * months_elapsed + hours_elapsed // 24 + conjunction_parts = 1080 * (hours_elapsed % 24) + parts_elapsed % 1080 + + adjusted_day = _apply_postponement(conjunction_day, conjunction_parts, year) + + # Second postponement: Rosh Hashanah cannot fall on Sunday, Wednesday, + # or Friday — push to the next day. + if adjusted_day % 7 in (0, 3, 5): + adjusted_day += 1 + + return adjusted_day + + +def _apply_postponement(conjunction_day, conjunction_parts, year): + """Apply the dehiyyah (postponement) rules to the molad. + + The Hebrew calendar has three postponement rules: + 1. Molad Zakein: if the molad occurs at or after noon (>= 19440 parts), + postpone by one day. + 2. Gatarad: if the molad falls on Tuesday (day 2) at or after 9924 parts + in a non-leap year, postpone by one day. + 3. Betutakafot: if the molad falls on Monday (day 1) at or after 16789 + parts in a year following a leap year, postpone by one day. + + Parameters + ---------- + conjunction_day : int + The raw day of the molad (lunar conjunction). + conjunction_parts : int + The parts (1/1080 of an hour) of the molad within the day. + year : int + The Hebrew year being calculated. + + Returns + ------- + int + The adjusted day after applying postponement rules. + """ + molad_zakein = conjunction_parts >= 19440 + gatarad = (conjunction_day % 7 == 2 and conjunction_parts >= 9924 + and not _is_leap(year)) + betutakafot = (conjunction_day % 7 == 1 and conjunction_parts >= 16789 + and _is_leap(year - 1)) + + if molad_zakein or gatarad or betutakafot: + return conjunction_day + 1 + return conjunction_day def _days_in_year(year): @@ -146,33 +182,42 @@ def _short_kislev(year): def _month_length(year, month): - """Months start with Nissan (Nissan is 1 and Tishrei is 7)""" - if month in [1, 3, 5, 7, 11]: + """Return the number of days in a Hebrew month. + + Months start with Nissan (Nissan is 1 and Tishrei is 7). + + Fixed-length months are looked up directly. Variable-length months + (Cheshvan=8, Kislev=9, and Adar=12) depend on the year type. + """ + # Fixed 30-day months: Nissan, Sivan, Av, Tishrei, Shevat + _THIRTY_DAY_MONTHS = frozenset({1, 3, 5, 7, 11}) + # Fixed 29-day months: Iyar, Tammuz, Elul, Teves, Adar II + _TWENTY_NINE_DAY_MONTHS = frozenset({2, 4, 6, 10, 13}) + + if month in _THIRTY_DAY_MONTHS: return 30 - if month in [2, 4, 6, 10, 13]: + if month in _TWENTY_NINE_DAY_MONTHS: return 29 if month == 12: - if _is_leap(year): - return 30 - return 29 - if month == 8: # if long Cheshvan return 30, else return 29 - if _long_cheshvan(year): - return 30 - return 29 - if month == 9: # if short Kislev return 29, else return 30 - if _short_kislev(year): - return 29 - return 30 + return 30 if _is_leap(year) else 29 + if month == 8: + return 30 if _long_cheshvan(year) else 29 + if month == 9: + return 29 if _short_kislev(year) else 30 raise ValueError('Invalid month') def _month_name(year, month, hebrew): + """Return the name of a Hebrew month. + + In a leap year, months >= 12 are shifted by one index position + because the names list includes both 'Adar 1' and 'Adar 2'. + """ index = month if month < 12 or not _is_leap(year): index -= 1 - if hebrew: - return MONTH_NAMES_HEBREW[index] - return MONTH_NAMES[index] + names = MONTH_NAMES_HEBREW if hebrew else MONTH_NAMES + return names[index] def _monthslist(year):