--- Julian calendar conversions, Roman calendar, AUC/Olympiad helpers. -- Ported from "Calendrical Calculations" (4th edition) -- by Nachum Dershowitz and Edward M. Reingold. -- Original Lisp code (CALENDRICA 4.0) is Apache 2.0 licensed. -- @module calendrica-julian -- @release 0.1 2026-07-19 local M = {} local basic = require("calendrica-basic") local gregorian = require("calendrica-gregorian") local astro = require("calendrica-astro") -- === Month constants (re-exported for convenience) === -- These mirror the Gregorian month constants since Julian months are the same. local JANUARY = gregorian.JANUARY local FEBRUARY = gregorian.FEBRUARY local MARCH = gregorian.MARCH local APRIL = gregorian.APRIL local MAY = gregorian.MAY local JUNE = gregorian.JUNE local JULY = gregorian.JULY local AUGUST = gregorian.AUGUST local SEPTEMBER = gregorian.SEPTEMBER local OCTOBER = gregorian.OCTOBER local NOVEMBER = gregorian.NOVEMBER local DECEMBER = gregorian.DECEMBER -- === Roman calendar event constants === local KALENDS = 1 local NONES = 2 local IDES = 3 -- === Era helpers === --- Return a negative year number representing `n` BCE on the Julian calendar. -- @tparam number n Positive year count before the common era. -- @treturn number Negative Julian year. function M.bce(n) return -n end local bce = M.bce --- Return a positive year number representing `n` CE on the Julian calendar. -- @tparam number n Year in the common era. -- @treturn number Julian year. function M.ce(n) return n end -- === Epoch === local JULIAN_EPOCH = gregorian.fixed_from_gregorian( gregorian.gregorian_date(0, gregorian.DECEMBER, 30) ) -- === Date constructor === --- Construct a Julian date from year, month, and day. -- @tparam number year Julian year (use bce/ce helpers). -- @tparam number month Month (1–12). -- @tparam number day Day of month. -- @treturn table {year, month, day} function M.julian_date(year, month, day) return {year, month, day} end local julian_date = M.julian_date -- === Leap year === --- True if `j_year` is a leap year on the Julian calendar. -- @tparam number j_year Julian year. -- @treturn boolean function M.julian_leap_year(j_year) if j_year > 0 then return j_year % 4 == 0 else return j_year % 4 == 3 end end local julian_leap_year = M.julian_leap_year -- === Conversion === --- Fixed date equivalent to the Julian date `j_date`. -- @tparam table j_date Julian date {year, month, day}. -- @treturn number Fixed date. function M.fixed_from_julian(j_date) local month = basic.standard_month(j_date) local day = basic.standard_day(j_date) local year = basic.standard_year(j_date) local y = year < 0 and year + 1 or year -- No year zero. local correction if month <= 2 then correction = 0 elseif julian_leap_year(year) then correction = -1 else correction = -2 end return (JULIAN_EPOCH - 1) -- Days before start of calendar. + 365 * (y - 1) -- Ordinary days since epoch. + basic.quotient(y - 1, 4) -- Leap days since epoch. + basic.quotient(367 * month - 362, 12) -- Days in prior months this year + correction -- Correct for 28- or 29-day Feb. + day -- Days so far this month. end local fixed_from_julian = M.fixed_from_julian --- Julian date {year, month, day} corresponding to fixed `date`. -- @tparam number date Fixed date. -- @treturn table {year, month, day} function M.julian_from_fixed(date) local approx = basic.quotient( 4 * (date - JULIAN_EPOCH) + 1464, 1461 ) local year = approx <= 0 and approx - 1 or approx -- No year 0. local prior_days = date - fixed_from_julian(julian_date(year, JANUARY, 1)) local correction if date < fixed_from_julian(julian_date(year, MARCH, 1)) then correction = 0 elseif julian_leap_year(year) then correction = 1 else correction = 2 end local month = basic.quotient(12 * (prior_days + correction) + 373, 367) local day = 1 + date - fixed_from_julian(julian_date(year, month, 1)) return julian_date(year, month, day) end local julian_from_fixed = M.julian_from_fixed -- === Roman calendar === --- Construct a Roman calendar date. -- @tparam number year Julian year. -- @tparam number month Month (1–12). -- @tparam number event Roman event (KALENDS, NONES, or IDES). -- @tparam number count Count before the event. -- @tparam boolean leap True for the leap-day variant in March of a leap year. -- @treturn table {year, month, event, count, leap} function M.roman_date(year, month, event, count, leap) return {year, month, event, count, leap} end local roman_date = M.roman_date local function roman_year(date) return date[1] end local function roman_month(date) return date[2] end local function roman_event(date) return date[3] end local function roman_count(date) return date[4] end local function roman_leap(date) return date[5] end -- Day of Ides in Roman month. local function ides_of_month(month) if month == MARCH or month == MAY or month == JULY or month == OCTOBER then return 15 else return 13 end end -- Day of Nones in Roman month. local function nones_of_month(month) return ides_of_month(month) - 8 end --- Fixed date for Roman name `r_date`. -- @tparam table r_date Roman date {year, month, event, count, leap}. -- @treturn number Fixed date. function M.fixed_from_roman(r_date) local leap = roman_leap(r_date) local count = roman_count(r_date) local event = roman_event(r_date) local month = roman_month(r_date) local year = roman_year(r_date) local base if event == KALENDS then base = fixed_from_julian(julian_date(year, month, 1)) elseif event == NONES then base = fixed_from_julian( julian_date(year, month, nones_of_month(month)) ) else -- IDES base = fixed_from_julian( julian_date(year, month, ides_of_month(month)) ) end local leap_offset if julian_leap_year(year) and month == MARCH and event == KALENDS and count >= 6 and count <= 16 then leap_offset = 0 -- After Ides until leap day. else leap_offset = 1 end return base - count + leap_offset + (leap and 1 or 0) -- leap is true or false end local fixed_from_roman = M.fixed_from_roman --- Roman name for fixed `date`. -- @tparam number date Fixed date. -- @treturn table Roman date {year, month, event, count, leap}. function M.roman_from_fixed(date) local j_date = julian_from_fixed(date) local month = basic.standard_month(j_date) local day = basic.standard_day(j_date) local year = basic.standard_year(j_date) local month_prime = basic.amod(month + 1, 12) local year_prime if month_prime ~= 1 then year_prime = year elseif year ~= -1 then year_prime = year + 1 else year_prime = 1 end local kalends1 = fixed_from_roman( roman_date(year_prime, month_prime, KALENDS, 1, false) ) if day == 1 then return roman_date(year, month, KALENDS, 1, false) elseif day <= nones_of_month(month) then return roman_date(year, month, NONES, 1 + nones_of_month(month) - day, false) elseif day <= ides_of_month(month) then return roman_date(year, month, IDES, 1 + ides_of_month(month) - day, false) elseif month ~= FEBRUARY or not julian_leap_year(year) then -- After the Ides, in a month that is not February of a leap year. return roman_date(year_prime, month_prime, KALENDS, 1 + kalends1 - date, false) elseif day < 25 then -- February of a leap year, before leap day. return roman_date(year, MARCH, KALENDS, 30 - day, false) else -- February of a leap year, on or after leap day. return roman_date(year, MARCH, KALENDS, 31 - day, day == 25 and true or false) end end -- === AUC (Ab Urbe Condita) === -- Julian year of the founding of Rome. local YEAR_ROME_FOUNDED = bce(753) --- AUC year equivalent to Julian year `j_year`. -- @tparam number j_year Julian year. -- @treturn number AUC year. function M.auc_year_from_julian(j_year) if j_year >= YEAR_ROME_FOUNDED and j_year <= -1 then return j_year - YEAR_ROME_FOUNDED + 1 else return j_year - YEAR_ROME_FOUNDED end end --- Julian year equivalent to AUC year `year`. -- @tparam number year AUC year. -- @treturn number Julian year. function M.julian_year_from_auc(year) if year >= 1 and year <= -YEAR_ROME_FOUNDED then return year + YEAR_ROME_FOUNDED - 1 else return year + YEAR_ROME_FOUNDED end end -- === Olympiads === -- Julian year at which Olympiads start. local OLYMPIAD_START = bce(776) local function olympiad(cycle, year) return {cycle, year} end local function olympiad_cycle(o_date) return o_date[1] end local function olympiad_year(o_date) return o_date[2] end --- Olympiad {cycle, year} corresponding to Julian year `j_year`. -- @tparam number j_year Julian year. -- @treturn table {cycle, year} function M.olympiad_from_julian_year(j_year) local years = j_year - OLYMPIAD_START - (j_year >= 0 and 1 or 0) return olympiad( 1 + basic.quotient(years, 4), 1 + years % 4 ) end --- Julian year corresponding to Olympiad `o_date`. -- @tparam table o_date Olympiad {cycle, year}. -- @treturn number Julian year. function M.julian_year_from_olympiad(o_date) local cycle = olympiad_cycle(o_date) local year = olympiad_year(o_date) local years = OLYMPIAD_START + 4 * (cycle - 1) + year - 1 if years < 0 then return years else return years + 1 end end -- === Julian date in Gregorian year === --- Fixed dates of Julian month `j_month`, day `j_day` that fall in Gregorian year `g_year`. -- @tparam number j_month Julian month (1–12). -- @tparam number j_day Julian day. -- @tparam number g_year Gregorian year. -- @treturn table List of fixed dates (0, 1, or 2 entries). function M.julian_in_gregorian(j_month, j_day, g_year) local jan1 = gregorian.gregorian_new_year(g_year) local y = basic.standard_year(julian_from_fixed(jan1)) local y_prime = y == -1 and 1 or y + 1 local date0 = fixed_from_julian(julian_date(y, j_month, j_day)) local date1 = fixed_from_julian(julian_date(y_prime, j_month, j_day)) return basic.list_range( {date0, date1}, gregorian.gregorian_year_range(g_year) ) end local julian_in_gregorian = M.julian_in_gregorian -- === Seasons (astronomical, requires calendrica-astro.lua) === -- Moments of a season in a Gregorian year given cycle length and start. local function cycle_in_gregorian(season, g_year, cap_L, start) local year = gregorian.gregorian_year_range(g_year) local pos = (season / 360) * cap_L local cap_delta = pos - (start % cap_L) return basic.positions_in_range(pos, cap_L, cap_delta, year) end -- Moments of Julian season in a Gregorian year. local function julian_season_in_gregorian(season, g_year) local cap_Y = 365 + astro.hr(6) local offset = (season / 360) * cap_Y return cycle_in_gregorian( season, g_year, cap_Y, fixed_from_julian(julian_date(bce(1), MARCH, 23)) + offset ) end -- === Eastern Orthodox Christmas === --- Fixed dates of Eastern Orthodox Christmas (Julian Dec 25) in Gregorian year `g_year`. -- Returns a list of 0 or 1 dates. -- @tparam number g_year Gregorian year. -- @treturn table List of fixed dates. function M.eastern_orthodox_christmas(g_year) return julian_in_gregorian(DECEMBER, 25, g_year) end -- === Exports === --- Moments of Julian solar season in Gregorian year `g_year`. -- Uses the Julian year length (365.25 days) rather than the tropical year. -- `season` is a solar longitude (0=spring, 90=summer, 180=autumn, 270=winter). -- @function julian_season_in_gregorian -- @tparam number season Solar longitude of season (degrees). -- @tparam number g_year Gregorian year. -- @treturn {number,...} Moments. M.julian_season_in_gregorian = julian_season_in_gregorian --- January month number (1). M.JANUARY = JANUARY --- February month number (2). M.FEBRUARY = FEBRUARY --- March month number (3). M.MARCH = MARCH --- April month number (4). M.APRIL = APRIL --- May month number (5). M.MAY = MAY --- June month number (6). M.JUNE = JUNE --- July month number (7). M.JULY = JULY --- August month number (8). M.AUGUST = AUGUST --- September month number (9). M.SEPTEMBER = SEPTEMBER --- October month number (10). M.OCTOBER = OCTOBER --- November month number (11). M.NOVEMBER = NOVEMBER --- December month number (12). M.DECEMBER = DECEMBER --- Roman calendar event class: Kalends. M.KALENDS = KALENDS --- Roman calendar event class: Nones. M.NONES = NONES --- Roman calendar event class: Ides. M.IDES = IDES return M