# SPDX-License-Identifier: MIT # calendars — decide what a calendar rule says, and calculate with dates. # # Two halves that share nothing but the subject. # # STRUCTURE. You supply three kinds of record as facts. # # Years (`calendars.Year`), by their number. The rules conclude whether each # is a leap year under the Julian rule (every fourth year) and under the # Gregorian rule (every fourth year, except a century unless it divides by # 400), and the years the two disagree about: # # import calendars # calendars.years(1899, 1901) # print(calendars.differ(1900), calendars.differ(1901)) # n = why_not(calendars.Year.y1900.gregorian_leap) # # Reforms (`Reform`), each the last day of one calendar in a place and the # first of the other, as year*10000 + month*100 + day. The module counts the # days the reform skipped: # # calendars.reform("rome", 1582, 10, 4, 1582, 10, 15, "Inter gravissimas") # print(calendars.skipped("rome")) # # Days (`Day`), written the same way. A rule marks a day that falls in the gap # of a reform, a day that place never had; the day is marked and kept, never # dropped: # # calendars.day("d1", 1582, 10, 10) # print(calendars.missing("d1", "rome"), calendars.missing("d1", "paris")) # # Each conclusion answers `why`, and `why_not` names the premise that failed. # A corrected fact withdraws what rested on it: a reform's first day rewritten # moves the gap. Where two sources give different days for one reform, both # values are reasoned about, the day each one makes missing is marked, no # query refuses, and `unsettled()` names what they disagree about. # # CALCULATION. Value scripts that return a value and write nothing: the # length of a month, the weekday of a date, Easter, the day number of a date # under each rule, and the span between two instants. # # What the date values hold, said here once: # - `datetime` and `duration` count in the proleptic Gregorian calendar: # the Gregorian rule is applied to years before any place adopted it, and # no reform's gap is in it. A day inside a reform's gap is a valid # `datetime`; the rules above are what say that a place never had it. # - The range is years 1 to 9999. # - A day is 86,400 seconds. A leap second has no place in a `datetime`. # - A `datetime` is a UTC instant written with a trailing Z. An offset is a # `duration` you add. # - A span longer than about 292 years is not representable: `span` refuses # it, so measure a longer one in days with `day_number`. # - A `duration` has days, hours, minutes and seconds and no months or # years, since neither has a fixed number of seconds. A month is # calculated with `month_length`. # - A date that names no real moment, such as 30 February, is a value until # something uses it, and is refused then. `date_exists` asks first. # - A year, month and day given to a script below as three integers are not # checked unless the script says so; `date_exists` checks them. # # Sources, fetched 2026-11-08: # - Inter gravissimas is dated 24 February 1582; Thursday 4 October 1582 was # followed by Friday 15 October 1582, and the Gregorian rule makes every # year divisible by four a leap year except those divisible by 100 and not # by 400 (https://en.wikipedia.org/wiki/Gregorian_calendar). # - In Britain the day after 2 September 1752 was 14 September 1752, and 11 # days were omitted (https://en.wikipedia.org/wiki/Calendar_(New_Style)_Act_1750). # - Easter is the first Sunday after the Paschal full moon, on a date from # 22 March to 25 April inclusive; the integer algorithm below is the # Meeus/Jones/Butcher one, for the Gregorian calendar # (https://en.wikipedia.org/wiki/Date_of_Easter and # https://en.wikipedia.org/wiki/Computus). # - The weekday is Sakamoto's method, which counts from Sunday as 0 for the # Gregorian calendar (https://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week). # - UTC has had 27 leap seconds, the last at the end of 2016 # (https://en.wikipedia.org/wiki/Leap_second). # # Integer division and remainder are exact. A reform's skipped days are # counted from the two day numbers `julian_day_number` returns for its last # and first days, so they hold across a month's end. # ── Years as facts ─────────────────────────────────────────────────────────── class Year: # A year, by a name `y` that `years()` and `year()` give it, and # `number`. The remainders `r4`, `r100` and `r400` are computed from it. # The rules conclude `julian_leap` (divisible by 4) and `gregorian_leap` # (by 4 and not by 100, or by 400), and `differ`, the Julian leap year that # is not a Gregorian one. A year that is not a Gregorian leap year gets no # `gregorian_leap` value at all, so `why_not` explains its absence. number: int r4: int = number % 4 r100: int = number % 100 r400: int = number % 400 julian_leap: bool gregorian_leap: bool differ: bool @rule def julian_leap_year(): for y in Year: if y.r4 == 0: y.julian_leap = True @rule def gregorian_leap_ordinary(): for y in Year: if y.r4 == 0 and y.r100 != 0: y.gregorian_leap = True @rule def gregorian_leap_fourth_century(): for y in Year: if y.r400 == 0: y.gregorian_leap = True @rule def years_differ(): for y in Year: if y.julian_leap == True and absent(y.gregorian_leap): y.differ = True # ── Reforms as facts ───────────────────────────────────────────────────────── class Reform: # A calendar reform in a place, by a name you choose. `last` is the last # day of the old calendar and `first` the first of the new, each as # year*10000 + month*100 + day. `last_count` and `first_count` are the # day numbers on the Julian calendar that `reform()` writes beside them, # and `skipped` is the days between, `first_count - last_count - 1`. last: int first: int last_count: int first_count: int skipped: int = first_count - last_count - 1 class Day: # A day, by a name you choose, and `ymd`, year*10000 + month*100 + day. ymd: int class Gap: # The relation `Gap.day.place`, True for each day that falls after a # reform's last day of the old calendar and before its first day of the # new, a day that place never had. pass @rule def day_in_the_gap(): for d in Day, r in Reform: if d.ymd > r.last and d.ymd < r.first: Gap.d.r = True # ── Writing facts ──────────────────────────────────────────────────────────── def years(first_year: int, last_year: int): # Each year from first_year to last_year inclusive, named y. for n in range(first_year, last_year + 1): s = "y" + str(n) update(Year(s).number, n, "years") def year(n: int): # One year, named y. s = "y" + str(n) update(Year(s).number, n, "years") def reform(name: string, last_year: int, last_month: int, last_day: int, first_year: int, first_month: int, first_day: int, source: string): # A reform: the last day of the old calendar and the first of the new, and # the source that says so. Write it again with another source and another # first day to hold the rival value beside the first. last = last_year * 10000 + last_month * 100 + last_day first = first_year * 10000 + first_month * 100 + first_day update(Reform(name).last, last, source) update(Reform(name).first, first, source) update(Reform(name).last_count, julian_day_number(last_year, last_month, last_day), source) update(Reform(name).first_count, julian_day_number(first_year, first_month, first_day), source) def day(name: string, y: int, m: int, d: int): # A day, as the three integers. update(Day(name).ymd, y * 10000 + m * 100 + d, "days") # ── Reading what the rules concluded ───────────────────────────────────────── # # Each reader reads every value in force, so a fact two sources disagree # about is reported with both values rather than refused. def true_for(values: claim_list, name: string) -> bool: for v in values: parts = v.path.split(".") if parts[len(parts) - 2] == name and v.value == True: return True return False def julian_leap(n: int) -> bool: # Whether year n is a Julian leap year, as the rules concluded. vs = list_values(Year.julian_leap) return true_for(vs, "y" + str(n)) def gregorian_leap(n: int) -> bool: # Whether year n is a Gregorian leap year, as the rules concluded. vs = list_values(Year.gregorian_leap) return true_for(vs, "y" + str(n)) def differ(n: int) -> bool: # Whether the Julian rule makes year n a leap year and the Gregorian rule # does not. vs = list_values(Year.differ) return true_for(vs, "y" + str(n)) def years_that_differ() -> list: # The recorded years the two rules disagree about, sorted. out = [] vs = list_values(Year.differ) for v in vs: parts = v.path.split(".") n = int(parts[len(parts) - 2][1:]) if v.value == True and n not in out: out = out + [n] return sorted(out) def skipped(name: string) -> list: # The days a reform skipped, one value, or one for each source when the # sources disagree, sorted. Empty before the reform is written. out = [] vs = list_values(Reform.skipped) for v in vs: parts = v.path.split(".") if parts[len(parts) - 2] == name: match v.value: case int as k: if k not in out: out = out + [k] case default: pass return sorted(out) def missing(day_name: string, place: string) -> bool: # Whether the day falls in the gap of the place's reform, under any value # in force for the reform. vs = list_values(Gap.place) return true_for(vs, day_name) def unsettled() -> list: # Each reform or day that holds two different values for a fact you # write, as "reform rome first" and so on, sorted. out = [] for f in ["last", "first", "ymd"]: if f == "last": vs = list_values(Reform.last) label = "reform " elif f == "first": vs = list_values(Reform.first) label = "reform " else: vs = list_values(Day.ymd) label = "day " seen = {} for v in vs: parts = v.path.split(".") s = label + parts[len(parts) - 2] + " " + parts[len(parts) - 1] x = str(v.value) if s in seen and seen[s] != x and s not in out: out = out + [s] seen[s] = x return sorted(out) # ── Calculation: the calendar ──────────────────────────────────────────────── def is_gregorian_leap(y: int) -> bool: # Whether y is a leap year under the Gregorian rule, for any integer y. return (y % 4 == 0 and y % 100 != 0) or y % 400 == 0 def is_julian_leap(y: int) -> bool: # Whether y is a leap year under the Julian rule, every fourth year. return y % 4 == 0 def month_length(y: int, m: int) -> int: # The days in month m (1 to 12) of year y, Gregorian rule: 28 or 29 for # February, 30 or 31 otherwise. if m == 2: if is_gregorian_leap(y): return 29 return 28 if m == 4 or m == 6 or m == 9 or m == 11: return 30 return 31 def julian_month_length(y: int, m: int) -> int: # The days in month m of year y under the Julian rule. if m == 2: if is_julian_leap(y): return 29 return 28 if m == 4 or m == 6 or m == 9 or m == 11: return 30 return 31 def date_exists(y: int, m: int, d: int) -> bool: # Whether the Gregorian calendar has this day, in years 1 to 9999. if y < 1 or y > 9999 or m < 1 or m > 12 or d < 1: return False return d <= month_length(y, m) def weekday(y: int, m: int, d: int) -> int: # The weekday of a Gregorian date, 0 for Sunday to 6 for Saturday, by # Sakamoto's method; proleptic, so it holds for dates before any place # adopted the calendar. It does not check that the date exists. t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] yy = y if m < 3: yy = y - 1 return (yy + yy // 4 - yy // 100 + yy // 400 + t[m - 1] + d) % 7 def easter(y: int) -> list: # Easter Sunday of year y in the Gregorian calendar as [month, day], by # the Meeus/Jones/Butcher integers. Month is 3 or 4, and the date falls # from 22 March to 25 April. It is the Western date, for years from 1583. a = y % 19 b = y // 100 c = y % 100 d = b // 4 e = b % 4 f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i = c // 4 k = c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) // 451 month = (h + l - 7 * m + 114) // 31 day = (h + l - 7 * m + 114) % 31 + 1 return [month, day] def day_number(y: int, m: int, d: int) -> int: # The Gregorian day number, 1 for 1 January of year 1, as CPython's # toordinal returns it. The difference of two is the days between them. before = 0 for k in range(1, m): before = before + month_length(y, k) p = y - 1 return p * 365 + p // 4 - p // 100 + p // 400 + before + d def julian_day_number(y: int, m: int, d: int) -> int: # A day number on the Julian calendar, the same unit as day_number, whose # difference of two dates is the days between them under the Julian rule. # Its zero is not day_number's. before = 0 for k in range(1, m): before = before + julian_month_length(y, k) p = y - 1 return p * 365 + p // 4 + before + d def span(a: datetime, b: datetime) -> duration: # The time from a to b. Negative when b is earlier. return b - a def after(t: datetime, days: int) -> datetime: # The instant `days` whole days after t; negative days go back. The result # must lie in years 1 to 9999. return t + duration("P1D") * days