From e23c9d92d9b001682da9ce88bd38ac8f1a066d3c Mon Sep 17 00:00:00 2001 From: Brian Glass Date: Wed, 14 Oct 2026 15:19:39 -0400 Subject: [PATCH] Name Greek Sundays after the Gospel, and fix Clean Week feasts From issue #228's remaining title and fasting differences. Greek usage names ordinary Sundays for the Evangelist being read rather than counting from Pentecost. GreekDay now titles them "Nth Sunday of Matthew/Luke", numbered from the Gospel actually served, so the Lukan jump and the Theophany interpolation carry through. The Forefathers are the 11th Sunday of Luke, Zacchaeus the 15th, and the interpolated Canaanite Sunday is named as such. Sundays known only by a neighbouring feast (before/after Nativity and Theophany) take that feast as their title. antiochian.org agrees on 123 of 125 such Sundays, 2018-2026; the other two are a GOA/Antiochian Gospel split in January 2024. The July Fathers Sunday is the 4th Ecumenical Council in Greek practice, so it becomes a slavic/greek pair of rows like Oct 1's Protection. GOA lets a rank-4 feast keep its wine and oil on Clean Tuesday to Friday, where Clean Week's "no overrides" used to hold it strict; Clean Monday stays strict. That is goarch.org's practice on all four such days in its 2025-2035 calendar. The goarch.org fasting audit goes from 3678 to 3681 of 3683 days. Slavic titles and fasting are unchanged. Co-Authored-By: Claude Opus 5.5 --- calendarium/fasting.py | 23 ++++++ calendarium/liturgics/day.py | 75 +++++++++++++++++++ .../tests/data/fasting-characterization.txt | 2 +- calendarium/tests/test_liturgics.py | 66 ++++++++++++++++ docs/greek-fasting.md | 40 ++++++++-- fixtures/calendarium.json | 23 +++++- tools/fasting/goarch_audit.py | 2 +- 7 files changed, 221 insertions(+), 10 deletions(-) diff --git a/calendarium/fasting.py b/calendarium/fasting.py index da0efca..988ccf6 100644 --- a/calendarium/fasting.py +++ b/calendarium/fasting.py @@ -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) @@ -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, @@ -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: @@ -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 diff --git a/calendarium/liturgics/day.py b/calendarium/liturgics/day.py index 3de5214..ed7c0a4 100644 --- a/calendarium/liturgics/day.py +++ b/calendarium/liturgics/day.py @@ -1,6 +1,7 @@ import collections import copy import logging +import re from datetime import date, timedelta @@ -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 @@ -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] @@ -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, diff --git a/calendarium/tests/data/fasting-characterization.txt b/calendarium/tests/data/fasting-characterization.txt index 3150e60..179d32e 100644 --- a/calendarium/tests/data/fasting-characterization.txt +++ b/calendarium/tests/data/fasting-characterization.txt @@ -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 diff --git a/calendarium/tests/test_liturgics.py b/calendarium/tests/test_liturgics.py index 8e09f78..bb107ba 100644 --- a/calendarium/tests/test_liturgics.py +++ b/calendarium/tests/test_liturgics.py @@ -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 @@ -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 '

' -- non-empty and non-None, so truthy as a plain Python string, but with no diff --git a/docs/greek-fasting.md b/docs/greek-fasting.md index feb1913..9e3dfb1 100644 --- a/docs/greek-fasting.md +++ b/docs/greek-fasting.md @@ -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. @@ -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. @@ -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 diff --git a/fixtures/calendarium.json b/fixtures/calendarium.json index c195e44..46c8d09 100644 --- a/fixtures/calendarium.json +++ b/fixtures/calendarium.json @@ -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 } }, diff --git a/tools/fasting/goarch_audit.py b/tools/fasting/goarch_audit.py index f665348..4b39fcd 100644 --- a/tools/fasting/goarch_audit.py +++ b/tools/fasting/goarch_audit.py @@ -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 -----------------------------------------