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 -----------------------------------------