Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 87 additions & 42 deletions src/pyluach/gematria.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
-------
Expand All @@ -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
133 changes: 89 additions & 44 deletions src/pyluach/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down