--- High-level calendar and date API. -- -- A **date** is an immutable value tied to a **calendar**, created with `date` or `today`. -- A calendar is given either by the **name** of a built-in one — a string such as -- `"gregorian"`, `"hebrew"`, or `"islamic-umalqura"` — or as a **calendar object**, from -- `calendar(name)` or made with `calendar{...}` for a calendar of your own. The built-in -- names are fixed: nothing can be registered under a name, so they always mean the same. -- A date exposes its calendar fields (`year`, `month`, `day`, …), its fixed Rata Die day -- number `.rd`, and its calendar object `.calendar` (with `.name` and `.granularities`), -- and can be re-expressed in another calendar with `d:to(cal)` (also `d:as(cal)` / -- `d:date(cal)`), which returns a new date. -- -- The astronomical functions (`sunrise`, `sunset`, `moonrise`, `moonset`) return a -- **moment** — a date that also carries a time-of-day, adding `.hour`, `.minute`, -- `.second`, `.zone` (UTC offset), and the fractional `.moment`. A plain date has no -- time-of-day, so those read `nil` on it. -- -- See the @{manual.md|Manual} for a guided introduction, and the @{calendars.md|Calendars} -- and @{holidays.md|Holidays} pages for the full lists. -- -- @module calendrica -- @release 0.2 2026-10-13 -- @usage -- local calendrica = require("calendrica") -- local d = calendrica.date({year=2024, month=4, day=23}, "gregorian") -- local h = calendrica.date(d, "hebrew") -- the same day in the Hebrew calendar -- print(d, h) local M = {} -- === Module loading === local basic = require("calendrica-basic") local gregorian = require("calendrica-gregorian") local julian = require("calendrica-julian") local egyptian = require("calendrica-egyptian-armenian") local akan = require("calendrica-akan") local coptic = require("calendrica-coptic-ethiopic") local iso = require("calendrica-iso") local icelandic = require("calendrica-icelandic") local islamic = require("calendrica-islamic") local hebrew = require("calendrica-hebrew") local eccl = require("calendrica-ecclesiastical") local mayan = require("calendrica-mayan") local balinese = require("calendrica-balinese") local persian = require("calendrica-persian") local bahai = require("calendrica-bahai") local french = require("calendrica-french-revolutionary") local chinese = require("calendrica-chinese") local old_hindu = require("calendrica-old-hindu") local hindu = require("calendrica-modern-hindu") local civil = require("calendrica-modern-civil") local tibetan = require("calendrica-tibetan") local astro_lunar = require("calendrica-astronomical-lunar") local astro = require("calendrica-astro") -- === Date object === -- A date object stores an integer RD (`_rd`) and its calendar (`_cal`). It may also -- carry a time-of-day: `_time` is the fraction of the day in [0, 1) and `_zone` the -- UTC offset (hours) it is expressed in. For a *plain date* both are nil; for a -- *moment* (e.g. the result of `sunrise`) `_time` is set — even when it is exactly 0, -- so a genuine midnight moment stays distinct from a plain date. Presence of `_time`, -- not its value, is what marks a moment. local date_mt = {} -- Split a day fraction (0..1) into clock components, following calcal's -- time-of-day decomposition. local function time_parts(fraction) local x = 24 * fraction -- fractional day → hours local h = math.floor(x) local m = math.floor((x - h) * 60) local s = (x - h - m / 60) * 3600 -- leftover fraction of an hour → seconds return h, m, s end -- Format a UTC offset (hours, possibly fractional) as e.g. "UTC+2" or "UTC-5:30". local function format_zone(zone) local sign = zone < 0 and "-" or "+" local az = math.abs(zone) local h = math.floor(az) local m = math.floor((az - h) * 60 + 0.5) if m == 0 then return string.format("UTC%s%d", sign, h) end return string.format("UTC%s%d:%02d", sign, h, m) end -- Methods callable on a date object with `:` — all reproject the date to another -- calendar and return a **new** date (the object itself is immutable): -- d:date("hebrew") == d:as("hebrew") == d:to("hebrew") == date(d, "hebrew") local date_methods = {} date_methods.date = function(self, calendar) return M.date(self, calendar) end date_methods.as = date_methods.date date_methods.to = date_methods.date -- The date's fields in its calendar, computed by one `from_rd` call on first use and -- cached in `_fields`. local function date_fields(self) local fields = rawget(self, "_fields") if not fields then fields = rawget(self, "_cal").from_rd(rawget(self, "_rd")) rawset(self, "_fields", fields) end return fields end date_mt.__index = function(self, key) if key == "rd" then return rawget(self, "_rd") -- fixed Rata Die integer (the day) elseif key == "moment" then return rawget(self, "_rd") + (rawget(self, "_time") or 0) -- fractional RD instant elseif key == "calendar" then return rawget(self, "_cal").object -- the calendar object elseif key == "zone" then return rawget(self, "_zone") -- UTC offset (hours), or nil on a plain date elseif key == "hour" or key == "minute" or key == "second" then local t = rawget(self, "_time") if not t then return nil end -- plain date: no time-of-day local h, m, s = time_parts(t) if key == "hour" then return h elseif key == "minute" then return m else return s end end local method = date_methods[key] if method then return method end return date_fields(self)[key] end -- `pairs(d)` gives the calendar's fields in the order of its granularities (the -- field table `date` accepts); `rd`, `calendar`, the time and the methods are -- read by name only. date_mt.__pairs = function(self) local fields = date_fields(self) local keys, i = rawget(self, "_cal").granularities, 0 return function() i = i + 1 local k = keys[i] if k ~= nil then return k, fields[k] end end end date_mt.__tostring = function(self) local fields = date_fields(self) local parts = {} for _, k in ipairs(rawget(self, "_cal").granularities) do parts[#parts + 1] = k .. "=" .. tostring(fields[k]) end parts[#parts + 1] = "rd=" .. rawget(self, "_rd") local t = rawget(self, "_time") if t then -- a moment: append the time-of-day and zone local h, m, s = time_parts(t) local stamp = string.format("%02d:%02d:%02d", h, m, math.floor(s)) local zone = rawget(self, "_zone") if zone then stamp = stamp .. " " .. format_zone(zone) end parts[#parts + 1] = stamp end return rawget(self, "_cal").name .. "(" .. table.concat(parts, ", ") .. ")" end -- Date objects are immutable; to "change" one, create a new one with `date(...)`. -- The internal lazy field cache uses rawset, which bypasses this guard. date_mt.__newindex = function() error("date objects are read-only; create a new one with date(...)", 2) end -- Build a date object. `time` (day fraction) and `zone` are nil for a plain date and -- set for a moment; reprojection passes them through so conversion keeps the time. local function make_date(rd, calendar, time, zone) return setmetatable({_rd = rd, _cal = calendar, _time = time, _zone = zone}, date_mt) end -- Build a moment date object from a fractional RD (`moment`) at UTC offset `zone`. -- The integer part is the day; the fraction is the time-of-day (kept even when 0). local function make_moment(moment, calendar, zone) local rd = math.floor(moment) return make_date(rd, calendar, moment - rd, zone) end -- === Calendars === -- A calendar object is read-only. Its data (name, granularities, the wrapped -- from_rd/to_rd, and `object`, the object itself) is kept in `_c`, and a date stores -- that data. Only `name` and `granularities` (a copy) are public. local calendar_mt = {} calendar_mt.__index = function(self, key) local data = rawget(self, "_c") if key == "name" then return data.name elseif key == "granularities" then local copy = {} for i, k in ipairs(data.granularities) do copy[i] = k end return copy end return nil end -- `pairs(cal)` gives the public fields, `name` then `granularities` (a copy), not -- the internal data. calendar_mt.__pairs = function(self) local keys, i = { "name", "granularities" }, 0 return function() i = i + 1 local k = keys[i] if k ~= nil then return k, self[k] end end end calendar_mt.__newindex = function() error("calendar objects are read-only; create a new one with calendar{...}", 2) end calendar_mt.__tostring = function(self) return "calendar(" .. tostring(rawget(self, "_c").name) .. ")" end -- Build a calendar object. `from_rd` and `to_rd` are raw functions using positional -- tables; they are wrapped using `granularities` as the key list. `to_rd` may be nil -- (a read-only calendar, which cannot be used to construct a date from fields). local function new_calendar(name, granularities, from_rd, to_rd) local keys = {} for i, k in ipairs(granularities) do keys[i] = k end local function wrapped_from_rd(rd) local t, out = from_rd(rd), {} for i, k in ipairs(keys) do out[k] = t[i] end return out end local wrapped_to_rd if to_rd then wrapped_to_rd = function(fields) local t = {} for i, k in ipairs(keys) do t[i] = fields[k] end return to_rd(t) end end local data = { name = name, granularities = keys, from_rd = wrapped_from_rd, to_rd = wrapped_to_rd, } data.object = setmetatable({_c = data}, calendar_mt) return data.object end -- The built-in calendars, by name; defined below and never changed afterwards. local builtin = {} local function define(name, granularities, from_rd, to_rd) builtin[name] = new_calendar(name, granularities, from_rd, to_rd) end -- The calendar data (`_c`) of a calendar argument: a built-in name or a calendar object. local function resolve_calendar(calendar) if type(calendar) == "string" then local cal = builtin[calendar] if cal then return rawget(cal, "_c") end error("unknown calendar: " .. calendar, 3) elseif getmetatable(calendar) == calendar_mt then return rawget(calendar, "_c") end error("expected a calendar name or a calendar object, got " .. tostring(calendar), 3) end -- The built-in holidays, by name: g_year -> RD|date|list; defined below. local holiday_fns = {} -- The RD of a holiday-occurrence value: an RD integer or a date object (else nil). local function event_rd(v) if type(v) == "number" then return v elseif getmetatable(v) == date_mt then return rawget(v, "_rd") end return nil end -- Turn a raw holiday result — an RD, a date, a list of either, or nil — into a list of -- date objects in calendar `cal` (calendar data; Gregorian if nil). local function normalize(raw, cal) cal = cal or rawget(builtin.gregorian, "_c") local rd = event_rd(raw) if rd then return {make_date(rd, cal)} elseif type(raw) == "table" then -- a list of RDs and/or date objects local out = {} for _, v in ipairs(raw) do local r = event_rd(v) if r then out[#out + 1] = make_date(r, cal) end end return out else return {} end end -- === Core === --- @section Core --- A calendar object: a built-in calendar by name, or a new calendar. -- -- * `calendar(name)` returns the built-in calendar `name` (e.g. `"hebrew"`), or `nil` if -- there is none; see `calendars`. -- * `calendar{name = ..., granularities = {...}, from_rd = f, to_rd = g}` makes a new -- calendar. `granularities` is the ordered list of field names; `from_rd` maps an RD -- to a positional table of fields and `to_rd` (optional) maps a positional table back -- to an RD — the field names are matched to those positions. Omit `to_rd` for a -- read-only calendar, one that cannot be used to construct a date from fields. `name` -- is only for display (`tostring`); it is not registered, so a calendar of your own -- never clashes with a built-in one or another package's. -- -- A calendar object can be passed wherever a calendar name can. It is read-only and has -- the fields `name` and `granularities`, which `pairs` lists in that order. -- @tparam string|table spec A built-in calendar name, or the definition of a new calendar. -- @treturn table|nil Calendar object (nil for an unknown name). -- @raise If a definition lacks `granularities` or `from_rd`. -- @usage local hebrew = calendrica.calendar("hebrew") -- @usage local mine = calendrica.calendar{name = "mine", granularities = {"year", "day"}, -- from_rd = function(rd) return {rd // 365, rd % 365} end} function M.calendar(spec) if type(spec) == "string" then return builtin[spec] elseif getmetatable(spec) == calendar_mt then return spec elseif type(spec) == "table" then assert(type(spec.granularities) == "table", "calendar: `granularities` must be a list of field names") assert(type(spec.from_rd) == "function", "calendar: `from_rd` must be a function") assert(spec.to_rd == nil or type(spec.to_rd) == "function", "calendar: `to_rd` must be a function") return new_calendar(spec.name or "calendar", spec.granularities, spec.from_rd, spec.to_rd) end error("calendar: expected a calendar name or a definition table", 2) end --- Express a date in a calendar (a built-in name or a calendar object). -- -- The first argument is one of: -- -- * a **field table** — named fields interpreted in the given calendar, e.g. -- `date({year=2024, month=1, day=1}, "gregorian")`; -- * an existing **date** (from `date` or `today`), re-expressed in the given calendar, -- e.g. `date(d, "hebrew")`; -- * a **fixed Rata Die integer**, e.g. `date(739758, "gregorian")` (the inverse of `.rd`). -- -- @tparam table|number source Field table, date object, or RD integer. -- @tparam string|table calendar Calendar name (e.g. `"gregorian"`; see `calendars`) or -- calendar object. -- @treturn table Date object. Fields are accessible by name (e.g. `d.year`). -- @raise If the calendar is unknown, if constructing from fields on a read-only calendar, -- or if `source` is not a date object, field table, or RD integer. function M.date(source, calendar) local cal = resolve_calendar(calendar) if type(source) == "number" then return make_date(source, cal) -- wrap a fixed RD integer elseif getmetatable(source) == date_mt then -- re-express an existing date/moment, return make_date(rawget(source, "_rd"), cal, -- keeping any time-of-day and zone rawget(source, "_time"), rawget(source, "_zone")) elseif type(source) == "table" then assert(cal.to_rd, "calendar is read-only (no to_rd): " .. tostring(cal.name)) return make_date(cal.to_rd(source), cal) -- construct from named fields else error("date: first argument must be a date object, a field table, or an RD integer", 2) end end --- Return today's date in the given calendar. -- @tparam[opt] string|table calendar Calendar name or object. Defaults to `"gregorian"`. -- @treturn table Date object for today. -- @raise If the calendar name is unknown. function M.today(calendar) local t = os.date("*t") local rd = gregorian.fixed_from_gregorian({t.year, t.month, t.day}) local cal = resolve_calendar(calendar or "gregorian") return make_date(rd, cal) end -- === Built-in calendars (referenced by name; see the Calendars page for the full list) === define("gregorian", {"year","month","day"}, gregorian.gregorian_from_fixed, gregorian.fixed_from_gregorian) define("julian", {"year","month","day"}, julian.julian_from_fixed, julian.fixed_from_julian) define("roman", {"year","month","event","count","leap"}, julian.roman_from_fixed, julian.fixed_from_roman) define("iso", {"year","week","day"}, iso.iso_from_fixed, iso.fixed_from_iso) define("egyptian", {"year","month","day"}, egyptian.egyptian_from_fixed, egyptian.fixed_from_egyptian) define("armenian", {"year","month","day"}, egyptian.armenian_from_fixed, egyptian.fixed_from_armenian) define("coptic", {"year","month","day"}, coptic.coptic_from_fixed, coptic.fixed_from_coptic) define("ethiopic", {"year","month","day"}, coptic.ethiopic_from_fixed, coptic.fixed_from_ethiopic) define("islamic", {"year","month","day"}, islamic.islamic_from_fixed, islamic.fixed_from_islamic) define("islamic-observational", {"year","month","day"}, astro_lunar.observational_islamic_from_fixed, astro_lunar.fixed_from_observational_islamic) define("islamic-tbla", {"year","month","day"}, islamic.islamic_tbla_from_fixed, islamic.fixed_from_islamic_tbla) define("islamic-umalqura", {"year","month","day"}, islamic.islamic_umalqura_from_fixed, islamic.fixed_from_islamic_umalqura) define("islamic-saudi", {"year","month","day"}, astro_lunar.saudi_islamic_from_fixed, astro_lunar.fixed_from_saudi_islamic) define("islamic-turkish", {"year","month","day"}, astro_lunar.turkish_islamic_from_fixed, astro_lunar.fixed_from_turkish_islamic) define("islamic-diyanet", {"year","month","day"}, islamic.diyanet_from_fixed, islamic.fixed_from_diyanet) define("hebrew", {"year","month","day"}, hebrew.hebrew_from_fixed, hebrew.fixed_from_hebrew) define("hebrew-civil", {"year","month","day"}, hebrew.civil_hebrew_from_fixed, hebrew.fixed_from_civil_hebrew) define("hebrew-observational", {"year","month","day"}, astro_lunar.observational_hebrew_from_fixed, astro_lunar.fixed_from_observational_hebrew) define("persian", {"year","month","day"}, persian.persian_from_fixed, persian.fixed_from_persian) define("persian-arithmetic", {"year","month","day"}, persian.arithmetic_persian_from_fixed, persian.fixed_from_arithmetic_persian) define("french", {"year","month","day"}, french.french_from_fixed, french.fixed_from_french) define("french-arithmetic", {"year","month","day"}, french.arithmetic_french_from_fixed, french.fixed_from_arithmetic_french) define("bahai", {"major","cycle","year","month","day"}, bahai.bahai_from_fixed, bahai.fixed_from_bahai) define("bahai-astronomical", {"major","cycle","year","month","day"}, bahai.astro_bahai_from_fixed, bahai.fixed_from_astro_bahai) define("mayan-long-count", {"baktun","katun","tun","uinal","kin"}, mayan.mayan_long_count_from_fixed, mayan.fixed_from_mayan_long_count) define("mayan-haab", {"month","day"}, mayan.mayan_haab_from_fixed) define("mayan-tzolkin", {"number","name"}, mayan.mayan_tzolkin_from_fixed) define("aztec-xihuitl", {"month","day"}, mayan.aztec_xihuitl_from_fixed) define("aztec-tonalpohualli", {"number","name"}, mayan.aztec_tonalpohualli_from_fixed) define("balinese", {"luang","dwiwara","triwara","caturwara","pancawara", "sadwara","saptawara","asatawara","sangawara","dasawara"}, balinese.bali_pawukon_from_fixed) define("chinese", {"cycle","year","month","leap","day"}, chinese.chinese_from_fixed, chinese.fixed_from_chinese) define("japanese", {"cycle","year","month","leap","day"}, chinese.japanese_from_fixed, chinese.fixed_from_japanese) define("japanese-era", {"era","year","month","day"}, civil.japanese_era_from_fixed, civil.fixed_from_japanese_era) define("korean", {"year","month","leap","day"}, chinese.korean_from_fixed, chinese.fixed_from_korean) define("vietnamese", {"cycle","year","month","leap","day"}, chinese.vietnamese_from_fixed, chinese.fixed_from_vietnamese) define("old-hindu-solar", {"year","month","day"}, old_hindu.old_hindu_solar_from_fixed, old_hindu.fixed_from_old_hindu_solar) define("old-hindu-lunar", {"year","month","leap","day"}, old_hindu.old_hindu_lunar_from_fixed, old_hindu.fixed_from_old_hindu_lunar) define("hindu-solar", {"year","month","day"}, hindu.hindu_solar_from_fixed, hindu.fixed_from_hindu_solar) define("hindu-solar-astronomical", {"year","month","day"}, hindu.astro_hindu_solar_from_fixed, hindu.fixed_from_astro_hindu_solar) define("hindu-lunar", {"year","month","leap_month","day","leap_day"}, hindu.hindu_lunar_from_fixed, hindu.fixed_from_hindu_lunar) define("hindu-lunar-astronomical", {"year","month","leap_month","day","leap_day"}, hindu.astro_hindu_lunar_from_fixed, hindu.fixed_from_astro_hindu_lunar) define("indian-national", {"year","month","day"}, civil.indian_national_from_fixed, civil.fixed_from_indian_national) define("roc", {"year","month","day"}, civil.roc_from_fixed, civil.fixed_from_roc) define("thai-solar", {"year","month","day"}, civil.thai_solar_from_fixed, civil.fixed_from_thai_solar) define("bengali-revised", {"year","month","day"}, civil.bengali_revised_from_fixed, civil.fixed_from_bengali_revised) define("nanakshahi", {"year","month","day"}, civil.nanakshahi_from_fixed, civil.fixed_from_nanakshahi) define("amazigh", {"year","month","day"}, civil.amazigh_from_fixed, civil.fixed_from_amazigh) define("tibetan", {"year","month","leap_month","day","leap_day"}, tibetan.tibetan_from_fixed, tibetan.fixed_from_tibetan) define("icelandic", {"year","season","week","weekday"}, icelandic.icelandic_from_fixed, icelandic.fixed_from_icelandic) define("akan", {"prefix","stem"}, akan.akan_name_from_fixed) define("auc", {"year","month","day"}, function(rd) local t = julian.julian_from_fixed(rd) return {julian.auc_year_from_julian(t[1]), t[2], t[3]} end, function(t) return julian.fixed_from_julian({julian.julian_year_from_auc(t[1]), t[2], t[3]}) end ) define("olympiad", {"cycle","year","month","day"}, function(rd) local t = julian.julian_from_fixed(rd) local o = julian.olympiad_from_julian_year(t[1]) return {o[1], o[2], t[2], t[3]} end, function(t) return julian.fixed_from_julian({julian.julian_year_from_olympiad({t[1], t[2]}), t[3], t[4]}) end ) define("samaritan", {"year","month","day"}, astro_lunar.samaritan_from_fixed, astro_lunar.fixed_from_samaritan) define("babylonian", {"year","month","leap","day"}, astro_lunar.babylonian_from_fixed, astro_lunar.fixed_from_babylonian) -- === Holidays === --- @section Holidays -- holiday_fns is declared with the calendars above; the built-in holidays are assigned -- here, and never changed afterwards. -- Christian / Western holiday_fns["easter"] = eccl.easter holiday_fns["astronomical-easter"] = astro_lunar.astronomical_easter holiday_fns["orthodox-easter"] = eccl.orthodox_easter holiday_fns["pentecost"] = eccl.pentecost holiday_fns["christmas"] = gregorian.christmas holiday_fns["advent"] = gregorian.advent holiday_fns["epiphany"] = gregorian.epiphany holiday_fns["eastern-orthodox-christmas"] = julian.eastern_orthodox_christmas holiday_fns["coptic-christmas"] = coptic.coptic_christmas -- US civic holiday_fns["independence-day"] = gregorian.independence_day holiday_fns["labor-day"] = gregorian.labor_day holiday_fns["memorial-day"] = gregorian.memorial_day holiday_fns["election-day"] = gregorian.election_day holiday_fns["daylight-saving-start"] = gregorian.daylight_saving_start holiday_fns["daylight-saving-end"] = gregorian.daylight_saving_end -- Jewish holiday_fns["rosh-hashanah"] = hebrew.rosh_hashanah holiday_fns["sukkot"] = hebrew.sukkot holiday_fns["shavuot"] = hebrew.shavuot holiday_fns["passover"] = hebrew.passover holiday_fns["yom-kippur"] = hebrew.yom_kippur holiday_fns["purim"] = hebrew.purim holiday_fns["hanukkah"] = hebrew.hanukkah holiday_fns["tishah-be-av"] = hebrew.tishah_be_av holiday_fns["ta-anit-esther"] = hebrew.ta_anit_esther holiday_fns["yom-ha-zikkaron"] = hebrew.yom_ha_zikkaron -- Islamic (arithmetic calendar) holiday_fns["islamic-new-year"] = islamic.islamic_new_year holiday_fns["ramadan"] = islamic.ramadan holiday_fns["eid-al-fitr"] = islamic.eid_al_fitr holiday_fns["eid-al-adha"] = islamic.eid_al_adha holiday_fns["mawlid"] = islamic.mawlid -- Persian holiday_fns["nowruz"] = persian.nowruz -- Bahá'í holiday_fns["bahai-new-year"] = bahai.bahai_new_year holiday_fns["naw-ruz"] = bahai.naw_ruz holiday_fns["feast-of-ridvan"] = bahai.feast_of_ridvan holiday_fns["birth-of-the-bab"] = bahai.birth_of_the_bab -- Balinese holiday_fns["kajeng-keliwon"] = balinese.kajeng_keliwon holiday_fns["tumpek"] = balinese.tumpek -- Chinese holiday_fns["chinese-new-year"] = chinese.chinese_new_year holiday_fns["dragon-festival"] = chinese.dragon_festival holiday_fns["qing-ming"] = chinese.qing_ming -- Hindu holiday_fns["hindu-lunar-new-year"] = hindu.hindu_lunar_new_year holiday_fns["mesha-samkranti"] = function(g_year) return math.floor(hindu.mesha_samkranti(g_year)) end holiday_fns["diwali"] = hindu.diwali holiday_fns["maha-shivaratri"] = hindu.shiva holiday_fns["rama-navami"] = hindu.rama holiday_fns["sacred-wednesdays"] = hindu.sacred_wednesdays -- Tibetan holiday_fns["losar"] = tibetan.tibetan_new_year -- Gregorian holiday_fns["unlucky-fridays"] = gregorian.unlucky_fridays --- Return occurrences of a built-in holiday in a Gregorian year as date objects. -- @tparam string name Holiday name (e.g. `"easter"`, `"hanukkah"`). -- See `holiday_names` for the full list. -- @tparam number g_year Gregorian year. -- @tparam[opt] string|table calendar Calendar name or object for the returned dates. -- Defaults to `"gregorian"`. -- @treturn {table,...} List of date objects (may be empty if the holiday does -- not fall in the given year). -- @raise If `name` is not a built-in holiday, or `calendar` is unknown. function M.holiday(name, g_year, calendar) local fn = holiday_fns[name] assert(fn, "unknown holiday: " .. tostring(name)) return normalize(fn(g_year), calendar and resolve_calendar(calendar)) end --- Return all built-in holidays occurring in a Gregorian year, sorted by date. -- @tparam number g_year Gregorian year. -- @tparam[opt] string|table calendar Calendar name or object for the returned dates. -- Defaults to `"gregorian"`. -- @treturn {{name=string,dates={table,...}},...} List of `{name, dates}` records, -- one per holiday that has at least one occurrence in the year. -- @raise If `calendar` is unknown. function M.holidays(g_year, calendar) local cal = calendar and resolve_calendar(calendar) local result = {} for name, fn in pairs(holiday_fns) do local dates = normalize(fn(g_year), cal) if #dates > 0 then result[#result + 1] = {name = name, dates = dates} end end table.sort(result, function(a, b) return a.dates[1]._rd < b.dates[1]._rd end) return result end -- === Enumeration === --- @section Enumeration --- Return a sorted list of the built-in holiday names. -- @treturn {string,...} function M.holiday_names() local names = {} for k in pairs(holiday_fns) do names[#names + 1] = k end table.sort(names) return names end --- Return a sorted list of the built-in calendar names. -- `calendar(name)` gives each one's object, with its ordered field names in -- `.granularities`. -- @treturn {string,...} -- @usage for _, name in ipairs(calendrica.calendars()) do -- print(name, table.concat(calendrica.calendar(name).granularities, ", ")) -- end function M.calendars() local names = {} for name in pairs(builtin) do names[#names + 1] = name end table.sort(names) return names end -- === Astronomy === --- @section Astronomy -- Location objects are immutable value objects, the spatial counterpart of a date. The -- underlying array is `{latitude, longitude, elevation, zone}` (zone as a fraction of a -- day, the astro convention) so the low-level routines can index it positionally; the -- metatable adds named fields and a readable `__tostring` on top of that. `.zone` is -- reported back in hours to match the constructor. local location_mt = {} location_mt.__index = function(self, key) if key == "latitude" then return rawget(self, 1) elseif key == "longitude" then return rawget(self, 2) elseif key == "elevation" then return rawget(self, 3) elseif key == "zone" then return rawget(self, 4) * 24 -- day fraction → hours end end -- `pairs(loc)` gives the named fields, `latitude`, `longitude`, `elevation`, `zone` -- (in hours), not the positional slots. location_mt.__pairs = function(self) local keys, i = { "latitude", "longitude", "elevation", "zone" }, 0 return function() i = i + 1 local k = keys[i] if k ~= nil then return k, self[k] end end end location_mt.__tostring = function(self) return string.format("location(latitude=%s, longitude=%s, elevation=%s, zone=%s)", tostring(rawget(self, 1)), tostring(rawget(self, 2)), tostring(rawget(self, 3)), format_zone(rawget(self, 4) * 24)) end -- Read-only on the public (named) interface; the positional slots stay writable only as -- an internal detail the low-level routines rely on. location_mt.__newindex = function() error("location objects are read-only; create a new one with location(...)", 2) end --- Construct a location for use with astronomical functions. -- @tparam number latitude Degrees north (negative = south); any value, folded over the poles onto [-90, 90]. -- @tparam number longitude Degrees east (negative = west); any value, wrapped into [-180, 180). -- @tparam number elevation Metres above sea level (negative below). -- @tparam number zone UTC offset in hours (e.g. 2 for UTC+2, 5.5 for UTC+5:30). This is -- a raw offset — it is used only to shift between universal and standard time, so a value -- beyond the usual civil range (-12 to 14) simply shifts the moment (past ±24 h, the day). -- @treturn table Location object with fields `.latitude`, `.longitude`, `.elevation`, `.zone` -- (which `pairs` lists in that order). function M.location(latitude, longitude, elevation, zone) -- Latitude and longitude are periodic, so canonicalize rather than reject; elevation -- has no meaningful limit; zone is a raw additive UT<->standard offset, so it is passed -- through unchecked (an out-of-range value just shifts the resulting moment). -- Fold latitude over the poles onto [-90, 90]. Each pole crossing (an odd multiple of -- 180° from the equator) also flips the meridian by 180° — e.g. 100°N at λ is 80°N at -- λ+180 — so shift the longitude accordingly before wrapping it. if latitude < -90 or latitude > 90 then if math.floor((latitude + 90) / 180) % 2 ~= 0 then longitude = longitude + 180 end latitude = 90 - math.abs((latitude + 90) % 360 - 180) end -- Wrap longitude into [-180, 180); a wrapped meridian keeps the moment's day correct -- (190° and -170° name the same meridian). if longitude < -180 or longitude >= 180 then longitude = (longitude + 180) % 360 - 180 end -- The underlying astro routines carry the zone as a fraction of a day (the book's -- `hr` convention), so convert the friendlier hours offset here. return setmetatable(astro.location(latitude, longitude, elevation, astro.hr(zone)), location_mt) end -- Resolve a date argument to its RD integer and the calendar to report a moment in. -- Accepts a date object (uses its RD and calendar) or a raw RD integer (defaults the -- calendar to Gregorian). local function rd_and_cal(date) if type(date) == "number" then return date, rawget(builtin.gregorian, "_c") elseif getmetatable(date) == date_mt then return rawget(date, "_rd"), rawget(date, "_cal") else error("expected a date object or an RD integer", 3) end end --- Lunar phase (0–360°) at midnight of `date`. -- 0 = new moon, 90 = first quarter, 180 = full moon, 270 = last quarter. -- @tparam table|number date A date object or an RD integer. -- @treturn number Degrees. function M.lunar_phase(date) local rd = rd_and_cal(date) return astro.lunar_phase(rd) end --- List of date objects on which the moon reaches lunar phase `phase` during -- Gregorian year `g_year`. -- -- **Note:** this function's name is provisional and likely will change in a future version. -- @tparam number phase Lunar phase in degrees: 0 = new moon, 90 = first quarter, -- 180 = full moon, 270 = last quarter. -- @tparam number g_year Gregorian year. -- @tparam[opt] string|table calendar Calendar name or object for the returned dates. -- Defaults to `"gregorian"`. -- @treturn {table,...} Date objects, one per occurrence. -- @raise If `calendar` is unknown. -- @usage local new_moons = calendrica.lunar_phases(0, 2026) -- @usage local full_moons = calendrica.lunar_phases(180, 2026) -- TODO: the name `lunar_phases` is provisional — it takes a phase and returns dates, -- so it reads as a near-inverse of `lunar_phase`; revisit before a stable release. function M.lunar_phases(phase, g_year, calendar) local cal = resolve_calendar(calendar or "gregorian") local start = gregorian.gregorian_new_year(g_year) local finish = gregorian.gregorian_new_year(g_year + 1) -- Use the book's specialised new-moon search for phase 0; the general one otherwise. local next_phase = phase == astro.NEW and astro.new_moon_at_or_after or function(tee) return astro.lunar_phase_at_or_after(phase, tee) end local out, tee = {}, next_phase(start) while tee < finish do out[#out + 1] = make_date(math.floor(tee), cal) tee = next_phase(tee + 1) end return out end -- Wrap an astronomical event (a standard-time moment) as a moment date object in -- calendar `cal`, or return nil if the event does not occur. local function moment_result(moment, cal, loc) if not moment or moment == basic.BOGUS then return nil end -- astro stores the zone as a fraction of a day; expose it as an hours offset. return make_moment(moment, cal, astro.zone(loc) * 24) end --- Sunrise on `date` at `loc` as a moment, or nil if the sun does not rise. -- The result is a date object (in `date`'s calendar, or Gregorian if `date` is an RD -- integer) carrying the time-of-day: `.hour`, `.minute`, `.second`, `.zone`, and the -- fractional `.moment`. Reproject it to another calendar with `:to(name)` and the time -- is preserved. -- @tparam table|number date A date object or an RD integer. -- @tparam table loc A location (from `location`). -- @treturn table|nil Moment date object, or nil. function M.sunrise(date, loc) local rd, cal = rd_and_cal(date) return moment_result(astro.sunrise(rd, loc), cal, loc) end --- Sunset on `date` at `loc` as a moment, or nil if the sun does not set. -- See `sunrise` for the returned moment's fields. -- @tparam table|number date A date object or an RD integer. -- @tparam table loc A location (from `location`). -- @treturn table|nil Moment date object, or nil. function M.sunset(date, loc) local rd, cal = rd_and_cal(date) return moment_result(astro.sunset(rd, loc), cal, loc) end --- Moonrise on `date` at `loc` as a moment, or nil if the moon does not rise. -- See `sunrise` for the returned moment's fields. -- @tparam table|number date A date object or an RD integer. -- @tparam table loc A location (from `location`). -- @treturn table|nil Moment date object, or nil. function M.moonrise(date, loc) local rd, cal = rd_and_cal(date) return moment_result(astro.moonrise(rd, loc), cal, loc) end --- Moonset on `date` at `loc` as a moment, or nil if the moon does not set. -- See `sunrise` for the returned moment's fields. -- @tparam table|number date A date object or an RD integer. -- @tparam table loc A location (from `location`). -- @treturn table|nil Moment date object, or nil. function M.moonset(date, loc) local rd, cal = rd_and_cal(date) return moment_result(astro.moonset(rd, loc), cal, loc) end return M