--- Persian calendar conversions (astronomical and arithmetic). -- 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-persian -- @release 0.1 2026-07-19 local M = {} local basic = require("calendrica-basic") local julian = require("calendrica-julian") local gregorian = require("calendrica-gregorian") local astro = require("calendrica-astro") -- === Epoch and location === -- Fixed date of start of the Persian calendar (March 19, 622 CE Julian). local PERSIAN_EPOCH = julian.fixed_from_julian( julian.julian_date(julian.ce(622), julian.MARCH, 19) ) -- Location of Tehran, Iran. local TEHRAN = astro.location(35.68, 51.42, astro.mt(1100), astro.hr(3 + 1/2)) -- === Date constructor === -- Persian date constructor. local function persian_date(year, month, day) return {year, month, day} end -- === Astronomical Persian calendar === -- Universal time of true noon on fixed date in Tehran. local function midday_in_tehran(date) return astro.midday(date, TEHRAN) end -- Fixed date of Astronomical Persian New Year on or before fixed date. local function persian_new_year_on_or_before(date) local approx = astro.estimate_prior_solar_longitude( astro.SPRING, midday_in_tehran(date) ) return basic.next( math.floor(approx) - 1, function(day) return astro.solar_longitude(midday_in_tehran(day)) <= astro.SPRING + 2 end ) end --- Fixed date of Astronomical Persian date `p_date`. -- @tparam table p_date Persian date {year, month, day}. -- @treturn number Fixed date. function M.fixed_from_persian(p_date) local month = basic.standard_month(p_date) local day = basic.standard_day(p_date) local year = basic.standard_year(p_date) local new_year = persian_new_year_on_or_before( PERSIAN_EPOCH + 180 + math.floor( astro.MEAN_TROPICAL_YEAR * (year > 0 and year - 1 or year) ) ) local prior_months if month <= 7 then prior_months = 31 * (month - 1) else prior_months = 30 * (month - 1) + 6 end return (new_year - 1) + prior_months + day end local fixed_from_persian = M.fixed_from_persian --- Astronomical Persian date {year, month, day} corresponding to fixed `date`. -- @tparam number date Fixed date. -- @treturn table {year, month, day} function M.persian_from_fixed(date) local new_year = persian_new_year_on_or_before(date) local y = 1 + basic.round_half_to_even( (new_year - PERSIAN_EPOCH) / astro.MEAN_TROPICAL_YEAR ) local year = y > 0 and y or y - 1 -- No year zero. local day_of_year = 1 + date - fixed_from_persian(persian_date(year, 1, 1)) local month if day_of_year <= 186 then month = math.ceil(day_of_year / 31) else month = math.ceil((day_of_year - 6) / 30) end local day = date - fixed_from_persian(persian_date(year, month, 1)) + 1 return persian_date(year, month, day) end -- === Arithmetic Persian calendar === -- True if p_year is an arithmetic Persian leap year. local function arithmetic_persian_leap_year(p_year) local y = p_year > 0 and p_year - 474 or p_year - 473 local year = (y % 2820) + 474 return (year * 31 - 5) % 128 < 31 end --- Fixed date equivalent to arithmetic Persian date `p_date`. -- @tparam table p_date Persian date {year, month, day}. -- @treturn number Fixed date. function M.fixed_from_arithmetic_persian(p_date) local day = basic.standard_day(p_date) local month = basic.standard_month(p_date) local p_year = basic.standard_year(p_date) local y = p_year > 0 and p_year - 474 or p_year - 473 local year = (y % 2820) + 474 local prior_months if month <= 7 then prior_months = 31 * (month - 1) else prior_months = 30 * (month - 1) + 6 end return (PERSIAN_EPOCH - 1) + 1029983 * basic.quotient(y, 2820) + 365 * (year - 1) + basic.quotient(31 * year - 5, 128) + prior_months + day end local fixed_from_arithmetic_persian = M.fixed_from_arithmetic_persian -- Persian year corresponding to fixed date. local function arithmetic_persian_year_from_fixed(date) local d0 = date - fixed_from_arithmetic_persian(persian_date(475, 1, 1)) local n2820 = basic.quotient(d0, 1029983) local d1 = d0 % 1029983 local y2820 if d1 == 1029982 then y2820 = 2820 else y2820 = basic.quotient(128 * d1 + 46878, 46751) end local year = 474 + 2820 * n2820 + y2820 return year > 0 and year or year - 1 end --- Arithmetic Persian date {year, month, day} corresponding to fixed `date`. -- @tparam number date Fixed date. -- @treturn table {year, month, day} function M.arithmetic_persian_from_fixed(date) local year = arithmetic_persian_year_from_fixed(date) local day_of_year = 1 + date - fixed_from_arithmetic_persian(persian_date(year, 1, 1)) local month if day_of_year <= 186 then month = math.ceil(day_of_year / 31) else month = math.ceil((day_of_year - 6) / 30) end local day = date - fixed_from_arithmetic_persian(persian_date(year, month, 1)) + 1 return persian_date(year, month, day) end -- === Nowruz === -- Fixed date of Persian New Year (Nowruz) in Gregorian year g_year. --- Fixed date of Nowruz (Persian New Year) in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.nowruz(g_year) local persian_year = 1 + g_year - gregorian.gregorian_year_from_fixed(PERSIAN_EPOCH) local y = persian_year <= 0 and persian_year - 1 or persian_year return fixed_from_persian(persian_date(y, 1, 1)) end --- True if arithmetic Persian year `p_year` is a leap year. -- @function arithmetic_persian_leap_year -- @tparam number p_year Persian year. -- @treturn boolean M.arithmetic_persian_leap_year = arithmetic_persian_leap_year return M