Skip to content
Merged
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
23 changes: 23 additions & 0 deletions calendarium/fasting.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class Season:
# December until the 25th, even if it be Saturday or Sunday, we do not allow
# fish." Slavic only; Greek expresses the same tightening as a second season.
no_fish_before_nativity: bool = False
# A day of at least this rank is not held by the rows that cap it (Clean
# Week's "no overrides"); its own claims apply as they would elsewhere in
# the season. Off everywhere but LENT_GREEK_CLEAN_WEEK.
row_cap_exempt_rank: int = 99

def floor_for(self, weekday):
return self.floor.get(weekday, self.default_floor)
Expand Down Expand Up @@ -219,6 +223,17 @@ def cap_for(self, weekday, feast_level):
# goarch.org marks the whole week `fast-day`, Wednesday and Friday included.
CHEESEFARE_GREEK = Season('Cheesefare (Greek)', default_floor=D.MeatFast)

# GOA does not let Clean Week's "no overrides" hold against a polyeleos saint:
# the Finding of the Forerunner's Head (Feb 24) and the Forty Martyrs (Mar 9)
# keep their wine and oil there, as they do in the rest of Lent. goarch.org
# shows it every time one of them falls on Clean Tuesday to Friday in its
# 2025-2035 calendar -- 2026-02-24, 2033-03-09 and 2034-02-24 -- but not on
# Clean Monday, which stays strict (2031-02-24). Holy Week's caps are
# untouched; it is a different stretch of pdists.
LENT_GREEK_CLEAN_WEEK = Season('Great Lent (Greek, Clean Week)',
default_floor=D.Strict, default_cap=D.WineAndOil,
cap_exempt_rank=7, row_cap_exempt_rank=4)

ORDINARY_GREEK = Season('ordinary Wed/Fri (Greek)',
floor={SAT: D.WineAndOil, SUN: D.WineAndOil},
default_floor=D.Strict,
Expand All @@ -236,6 +251,11 @@ def greek_season(day):
if -55 <= day.pdist <= -49:
return CHEESEFARE_GREEK
return ORDINARY_GREEK
if day.fast_level == FastLevels.LentenFast and -47 <= day.pdist <= -44:
# Clean Tuesday to Friday. Clean Monday holds even against the feast:
# goarch.org keeps 2031-02-24 strict. The weekend has wine and oil
# regardless.
return LENT_GREEK_CLEAN_WEEK
if day.fast_level == FastLevels.ApostlesFast:
return APOSTLES_GREEK
if day.fast_level == FastLevels.NativityFast:
Expand Down Expand Up @@ -284,8 +304,11 @@ def resolve(season, weekday, feast_level, rows, no_fish=False,
if grant.rung > allowance:
allowance, winner = grant.rung, None

exempt_from_no_overrides = feast_level >= season.row_cap_exempt_rank
for index, cap in caps:
if cap < allowance:
if exempt_from_no_overrides and index == 10:
continue # the feast's claim stands
allowance, winner = cap, index
elif cap == allowance and winner is None:
winner = index # it agrees, and has the better label
Expand Down
75 changes: 75 additions & 0 deletions calendarium/liturgics/day.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import collections
import copy
import logging
import re

from datetime import date, timedelta

Expand Down Expand Up @@ -246,6 +247,7 @@ async def ainitialize(self):
await self._collect_commemorations()
await self._add_supplemental_commemorations()
await self._collect_ordo_readings()
self._apply_title_conventions()
self._apply_fasting_adjustments()
self._initialized = True

Expand Down Expand Up @@ -510,6 +512,9 @@ def _apply_fasting_adjustments(self):
"""Tradition-specific -- see SlavicDay/GreekDay."""
raise NotImplementedError

def _apply_title_conventions(self):
"""Rename the day's titles to the tradition's usage; see GreekDay."""

@cached_property
def fast_level_desc(self):
return FastLevelDesc[self.fast_level]
Expand Down Expand Up @@ -1007,6 +1012,76 @@ def _apply_fasting_adjustments(self):
fast -- see calendarium/fasting.py and docs/greek-fasting.md."""
fasting.apply(self, fasting.greek_season)

def _apply_title_conventions(self):
"""Name ordinary Sundays the Greek way, after the Gospel read.

Slavic usage counts Sundays after Pentecost. Greek usage names them for
the Evangelist being read -- the 12th Sunday of Luke is the Sunday that
reads Luke's 12th Sunday Gospel, wherever the Lukan jump and the
Theophany interpolation have put it -- so the number comes from
gospel_pdist (which GreekYear.lukan_sunday_numbers drives) rather than
from the calendar. Checked against the antiochian.org harvest,
2018-2026: 123 of its 125 Sundays named this way agree. The other two
are January 2024, where antiochian.org reads a different Gospel from
goarch.org and this app, and the name follows our Gospel.
"""
if self.weekday != Weekday.Sunday:
return

for i, title in enumerate(self.titles):
if _SLAVIC_SUNDAY_TITLE.match(title) or title == 'Sunday of Zacchaeus':
break
else:
return

if name := self._greek_sunday_name():
self.titles[i] = name
return

# The Sundays before and after Nativity and Theophany carry no number
# in Greek usage; they are known by the feast they lean on.
if sunday_feast := next((f for f in self.feasts if f.startswith('Sunday ')), None):
self.titles[i] = sunday_feast
self.feasts.remove(sunday_feast)
else:
del self.titles[i]

def _greek_sunday_name(self):
if self.pdist == self.pyear.forefathers:
# The Forefathers read their own Gospel, from the festal cycle, in
# the place of Luke's 11th.
return '11th Sunday of Luke'

gospel = self.gospel_pdist
if gospel is None:
return None
if gospel == -77:
# Zacchaeus, Luke's 15th Sunday Gospel, read from the coming
# year's Paschal cycle on the Sunday before the Triodion.
return '15th Sunday of Luke'
if gospel == 49 + 7:
return 'Sunday of All Saints'
if gospel == 49 + 17*7 and self.month in (1, 2):
# The 17th Sunday of Matthew, interpolated after Theophany.
return 'Sunday of the Canaanite Woman'

weeks, rest = divmod(gospel - 49, 7)
if rest or weeks < 2:
return None
if weeks <= 17:
return f'{_ordinal(weeks)} Sunday of Matthew'
if weeks <= 32:
return f'{_ordinal(weeks - 17)} Sunday of Luke'
return None


_SLAVIC_SUNDAY_TITLE = re.compile(r'\d+(st|nd|rd|th) Sunday after Pentecost')


def _ordinal(n):
suffix = 'th' if 10 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')
return f'{n}{suffix}'


_DAY_CLASSES = {
Tradition.Slavic: SlavicDay,
Expand Down
2 changes: 1 addition & 1 deletion calendarium/tests/data/fasting-characterization.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2243,7 +2243,7 @@
2026-02-21 greek w6 feast0 fast1 exc7 rows=0,7 -> meat
2026-02-22 greek w0 feast0 fast1 exc7 rows=0,7 -> meat
2026-02-23 greek w1 feast0 fast2 exc10 rows=0,10 -> meat,fish,dairy,eggs,wine,oil
2026-02-24 greek w2 feast4 fast2 exc10 rows=1,10 -> meat,fish,dairy,eggs,wine,oil
2026-02-24 greek w2 feast4 fast2 exc1 rows=1,10 -> meat,fish,dairy,eggs
2026-02-25 greek w3 feast1 fast2 exc10 rows=0,10 -> meat,fish,dairy,eggs,wine,oil
2026-02-26 greek w4 feast0 fast2 exc10 rows=0,10 -> meat,fish,dairy,eggs,wine,oil
2026-02-27 greek w5 feast1 fast2 exc10 rows=10 -> meat,fish,dairy,eggs,wine,oil
Expand Down
66 changes: 66 additions & 0 deletions calendarium/tests/test_liturgics.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,25 @@ async def test_lenten_wine_oil_exceptions_greek_stricter_than_slavic(self):
self.assertEqual(greek.fast_exception_desc, '')


async def test_clean_week_feast_keeps_wine_and_oil(self):
"""GOA lets a polyeleos saint's wine and oil through Clean Week's "no
overrides" -- but not on Clean Monday. goarch.org marks 2026-02-24
(Tuesday) `grapes` and 2031-02-24 (Monday) `strict-fast`; both are the
Finding of the Forerunner's Head. Slavic keeps both strict."""

for (year, month, day), greek_desc in (
((2026, 2, 24), 'Wine and Oil are Allowed'),
((2031, 2, 24), 'No overrides'),
):
with self.subTest(date=date(year, month, day)):
slavic = liturgics.Day(year, month, day, tradition=Tradition.Slavic)
greek = liturgics.Day(year, month, day, tradition=Tradition.Greek)
await slavic.ainitialize()
await greek.ainitialize()

self.assertEqual(slavic.fast_exception_desc, 'No overrides')
self.assertEqual(greek.fast_exception_desc, greek_desc)

class TestGreekLukanNumbering(TestCase):
"""GreekYear.lukan_sunday_numbers and theophany_interpolation, checked
against every Sunday in the antiochian.org official liturgical charts
Expand Down Expand Up @@ -842,6 +861,53 @@ def test_leavetaking_theophany_weekday_float(self):
self.assertNotIn(leavetaking_sunday, sunday_year.floats)



class TestGreekSundayTitles(TestCase):
"""Greek Sundays are named for the Gospel read, not counted from
Pentecost; see GreekDay._apply_title_conventions. Expected names are
antiochian.org's for the same dates."""

fixtures = ['calendarium.json', 'commemorations.json']

async def test_sunday_titles(self):
for (year, month, day), expected in (
((2026, 6, 7), 'Sunday of All Saints'),
((2026, 7, 12), '6th Sunday of Matthew'),
((2026, 9, 27), '1st Sunday of Luke'),
((2026, 11, 29), '13th Sunday of Luke'), # Lukan jump
((2026, 12, 13), '11th Sunday of Luke'), # Forefathers
((2026, 12, 20), 'Sunday before Nativity'), # no number in Greek usage
((2026, 1, 18), '12th Sunday of Luke'), # after Theophany
((2026, 1, 25), '15th Sunday of Luke'), # Zacchaeus
((2021, 2, 14), 'Sunday of the Canaanite Woman'),
((2021, 2, 7), '16th Sunday of Matthew'),
):
with self.subTest(date=date(year, month, day)):
pday = liturgics.Day(year, month, day, tradition=Tradition.Greek)
await pday.ainitialize()
self.assertEqual(pday.titles[0], expected)
self.assertEqual(pday.summary_title, expected)

async def test_sunday_feast_promoted_to_title_is_not_repeated(self):
pday = liturgics.Day(2026, 12, 20, tradition=Tradition.Greek)
await pday.ainitialize()
self.assertNotIn('Sunday before Nativity', pday.feasts)

async def test_slavic_sundays_still_count_from_pentecost(self):
pday = liturgics.Day(2026, 9, 27, tradition=Tradition.Slavic)
await pday.ainitialize()
self.assertEqual(pday.titles, ['17th Sunday after Pentecost'])

async def test_july_fathers_differ_by_tradition(self):
slavic = liturgics.Day(2026, 7, 19, tradition=Tradition.Slavic)
greek = liturgics.Day(2026, 7, 19, tradition=Tradition.Greek)
await slavic.ainitialize()
await greek.ainitialize()

self.assertIn('Fathers of the 1st Six Ecumenical Councils', slavic.feasts)
self.assertIn('Holy Fathers of the 4th Ecumenical Council', greek.feasts)
self.assertNotIn('Fathers of the 1st Six Ecumenical Councils', greek.feasts)

class TestHasStory(TestCase):
"""A handful of DayCommemoration.story rows are exactly '<p></p>' --
non-empty and non-None, so truthy as a plain Python string, but with no
Expand Down
40 changes: 33 additions & 7 deletions docs/greek-fasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ and Fridays.

### What this means for us

This app follows GOA and matches it on 3678 of 3683 days. It therefore sits at
This app follows GOA and matches it on 3681 of 3683 days (see *Clean Week*
below for the last rule added). It therefore sits at
roughly 94% against Antioch **by construction**, and that residue is Antioch
diverging from GOA rather than an error here -- worth knowing if the app is ever
reported as disagreeing with an Antiochian parish calendar.
Expand Down Expand Up @@ -292,12 +293,9 @@ arithmetic, three different Nov-1 weekdays) and
## Remaining / not investigated

- **Feb 24** (First/Second Finding of the Head of John the Baptist): never
landed inside actual Great Lent in any of the 4 years sampled (Pascha
timing kept it in the pre-Lenten Cheesefare/Meatfare season every time) --
no valid empirical test yet, positive or negative. Our own `common`
baseline also shows no override here (`fast_exception=0`), which may be a
pre-existing Slavic-side gap independent of the Greek question -- not
picked up here.
landed inside actual Great Lent in any of the 4 antiochian.org years
sampled. goarch.org has since settled the Clean Week case for GOA -- see
*Clean Week* below.
- **Fifth week Thursday** (the actual Great Canon day) and **Mar 26**
(Synaxis of the Archangel Gabriel): genuinely mixed empirical results (see
above), not confident enough to act on either way.
Expand All @@ -312,6 +310,34 @@ arithmetic, three different Nov-1 weekdays) and
instead (see Implementation above) -- both kinds of change turned out to
be needed, just for different fasts.

## Clean Week (2026-10-14)

Issue #228 reported Feb 24, 2026 -- Clean Tuesday, and the Finding of the
Forerunner's Head -- as wine and oil on goarch.org but strict here. Clean
Week's `common` rows carry "no overrides" (`fast_exception=10`), a cap that
held against the feast's own wine-and-oil row.

goarch.org's 2025-2035 calendar has four Clean Week weekdays with a rank-4
feast, and they split cleanly by day:

| date | day | feast | goarch.org |
|---|---|---|---|
| 2026-02-24 | Tue | Finding of the Head | wine and oil |
| 2031-02-24 | Mon | Finding of the Head | strict |
| 2033-03-09 | Wed | Forty Martyrs | wine and oil |
| 2034-02-24 | Fri | Finding of the Head | wine and oil |

So for Greek, Clean Tuesday to Friday let a rank-4 feast's claim through and
Clean Monday does not (`LENT_GREEK_CLEAN_WEEK` in `calendarium/fasting.py`).
Slavic is unchanged. The audit's two remaining differences contradict
goarch.org's own pattern in other years and are left alone: Feb 9, 2029 (Feb 9
on a Wednesday or Friday is strict in 2033 and 2035), and the Leavetaking of
Pascha in 2031 (fish in every other year).

**Holy Saturday stays wine only.** goarch.org marks it strict; the Typikon and
Antioch allow wine without oil. Brian chose the Typikon reading, so the audit
keeps scoring the two as equal (see `bucket()` in `tools/fasting/goarch_audit.py`).

## Bug found 2026-08-24: 9 dates with stale/never-ingested fast_exception

Brian noticed Aug 28, 2026 showing "Wine and Oil are Allowed" for Greek
Expand Down
23 changes: 22 additions & 1 deletion fixtures/calendarium.json
Original file line number Diff line number Diff line change
Expand Up @@ -16753,7 +16753,28 @@
"fast": 0,
"fast_exception": 0,
"flag": 0,
"tradition": "common",
"tradition": "slavic",
"fast_cap_exempt": null
}
},
{
"model": "calendarium.day",
"pk": 949,
"fields": {
"pdist": 1001,
"month": 0,
"day": 0,
"title": "",
"subtitle": "",
"feast_name": "Holy Fathers of the 4th Ecumenical Council",
"feast_level": 0,
"service": 0,
"service_note": "",
"story": null,
"fast": 0,
"fast_exception": 0,
"flag": 0,
"tradition": "greek",
"fast_cap_exempt": null
}
},
Expand Down
2 changes: 1 addition & 1 deletion tools/fasting/goarch_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Reads data/goarch_fasting.json, which holds GOA's own daily fasting
designations, and compares them to what this app serves for
`Tradition.Greek`. Last run 2026-09-09: **257/276, 93.1%**.
`Tradition.Greek`. Last run 2026-10-14: **3681/3683, 99.9%**.

Harvesting more months, when it is needed
-----------------------------------------
Expand Down
Loading