--- Basic arithmetic and calendar utilities. -- 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-basic -- @release 0.1 2026-07-19 local M = {} local begin, end_, to_radix, positions_in_range -- === Constants === -- M.TRUE = true (not used) -- M.FALSE = false (not used) local BOGUS = "bogus" local SUNDAY = 0 local MONDAY = 1 local TUESDAY = 2 local WEDNESDAY = 3 local THURSDAY = 4 local FRIDAY = 5 local SATURDAY = 6 local JD_EPOCH = -1721424.5 local MJD_EPOCH = 678576 local UNIX_EPOCH = 719163 -- === Arithmetic helpers === --- Whole part of `m`/`n`. -- @tparam number m Dividend. -- @tparam number n Divisor (non-zero). -- @treturn number Integer quotient (floor division). function M.quotient(m, n) return math.floor(m / n) end local quotient = M.quotient --- The value of (`x` mod `y`) with `y` instead of 0. -- @tparam number x Dividend. -- @tparam number y Modulus (non-zero). -- @treturn number Result in range [1..y]. function M.amod(x, y) return y + (x % (-y)) end --- The value of `x` shifted into the range [`a`..`b`). Returns `x` if `a` = `b`. -- @tparam number x Value to shift. -- @tparam number a Lower bound. -- @tparam number b Upper bound. -- @treturn number function M.mod3(x, a, b) if a == b then return x else return a + ((x - a) % (b - a)) end end local mod3 = M.mod3 --- Sum `expression(index)` for `index` = `initial` and successive integers, -- as long as `condition(index)` holds. -- @tparam func expression Function mapping index to a number. -- @tparam number initial Starting index. -- @tparam func condition Predicate; iteration continues while true. -- @treturn number function M.sum(expression, initial, condition) local result = 0 local i = initial while condition(i) do result = result + expression(i) i = i + 1 end return result end local sum = M.sum -- Product of expression(index) for index = initial and successive integers, -- as long as condition(index) holds. (local helper, not exported) local function prod(expression, initial, condition) local result = 1 local i = initial while condition(i) do result = result * expression(i) i = i + 1 end return result end --- First integer greater or equal to `initial` such that `condition` holds. -- @tparam number initial Starting value. -- @tparam func condition Predicate. -- @treturn number function M.next(initial, condition) local i = initial while not condition(i) do i = i + 1 end return i end --- Last integer greater or equal to `initial` such that `condition` holds. -- @tparam number initial Starting value. -- @tparam func condition Predicate; stops when false. -- @treturn number function M.final(initial, condition) local i = initial while condition(i) do i = i + 1 end return i - 1 end --- Sum of `body(i1,...,in)` for indices running simultaneously through parallel lists. -- `lists` is an array of arrays all of the same length; `body` receives one element -- from each list on each iteration. -- @tparam table lists Array of equal-length arrays. -- @tparam func body Function applied to one element from each list. -- @treturn number function M.sigma(lists, body) local result = 0 local n = #lists[1] for j = 1, n do local args = {} for i = 1, #lists do args[i] = lists[i][j] end result = result + body(table.unpack(args)) end return result end --- Use bisection to find the inverse of angular function `f` at `y` within interval `r`. -- @tparam func f Angular function to invert. -- @tparam number y Target value. -- @tparam table r Interval {lo, hi}. -- @treturn number function M.invert_angular(f, y, r) local varepsilon = 1 / 100000 local l = begin(r) local u = end_(r) while (u - l) >= varepsilon do local x = (l + u) / 2 if (f(x) - y) % 360 < 180 then u = x else l = x end end return (l + u) / 2 end --- Bisection search for `x` in [`lo`..`hi`] such that `end_condition` holds. -- `test` determines when to go left. -- @tparam number lo Lower bound. -- @tparam number hi Upper bound. -- @tparam func test Predicate; go left (upper = x) when true. -- @tparam func end_condition Predicate on (l, u); stop when true. -- @treturn number function M.binary_search(lo, hi, test, end_condition) local l = lo local u = hi local x while true do x = (l + u) / 2 if end_condition(l, u) then return x end if test(x) then u = x else l = x end end end --- Evaluate polynomial in `x` with coefficients `a` (from order 0 up). -- @tparam number x Argument. -- @tparam table a Array of coefficients, a[1] is the constant term. -- @treturn number function M.poly(x, a) if #a == 0 then return 0 end local result = 0 for i = #a, 1, -1 do result = a[i] + x * result end return result end --- Sign of `y`: returns -1, 0, or 1. -- @tparam number y -- @treturn number -1, 0, or 1. function M.sign(y) if y < 0 then return -1 elseif y > 0 then return 1 else return 0 end end --- Round `x` to the nearest integer using banker's rounding (half-to-even). -- Matches Common Lisp's `round`, used in `persian_from_fixed`. -- @tparam number x Value to round. -- @treturn number Integer result. function M.round_half_to_even(x) local f = math.floor(x) local diff = x - f -- Fractional part, always in [0, 1). if diff < 0.5 then return f -- Closer to floor. elseif diff > 0.5 then return f + 1 -- Closer to ceiling. else -- Exactly halfway: return whichever of floor/ceiling is even. return f % 2 == 0 and f or f + 1 end end -- === RD (Rata Die) === --- Identity function for fixed dates/moments (Rata Die). -- If internal timekeeping is shifted, change `epoch` inside this function. -- @tparam number tee Moment or fixed date. -- @treturn number function M.rd(tee) local epoch = 0 return tee - epoch end local rd = M.rd -- === Day of week === --- Day of the week of fixed `date` (0 = Sunday .. 6 = Saturday). -- @tparam number date Fixed date. -- @treturn number Day-of-week residue class. function M.day_of_week_from_fixed(date) return (date - rd(0) - SUNDAY) % 7 end -- === Standard date accessors === --- Month field of `date` = {year, month, day}. -- @tparam table date Standard date tuple. -- @treturn number Month. function M.standard_month(date) return date[2] end --- Day field of `date` = {year, month, day}. -- @tparam table date Standard date tuple. -- @treturn number Day. function M.standard_day(date) return date[3] end --- Year field of `date` = {year, month, day}. -- @tparam table date Standard date tuple. -- @treturn number Year. function M.standard_year(date) return date[1] end -- === Clock time === --- TYPE (hour minute second) -> clock-time --- Construct a clock time from hours, minutes, and seconds. --- @param hour integer --- @param minute integer --- @param second real --- @return clock-time local function time_of_day(hour, minute, second) -- luacheck: ignore return {hour, minute, second} end --- TYPE clock-time -> hour --- Hour field of `clock`. --- @param clock clock-time --- @return integer local function hour(clock) -- luacheck: ignore return clock[1] end --- TYPE clock-time -> minute --- Minute field of `clock`. --- @param clock clock-time --- @return integer local function minute(clock) -- luacheck: ignore return clock[2] end --- TYPE clock-time -> second --- Seconds field of `clock`. --- @param clock clock-time --- @return real local function seconds(clock) -- luacheck: ignore return clock[3] end -- === Moment / time conversions === --- Fixed date (floor) from moment `tee`. -- @tparam number tee Moment. -- @treturn number Fixed date. function M.fixed_from_moment(tee) return math.floor(tee) end local fixed_from_moment = M.fixed_from_moment --- Fractional time-of-day from moment `tee`. -- @tparam number tee Moment. -- @treturn number Time in [0, 1). function M.time_from_moment(tee) return tee % 1 end --- TYPE (list-of-reals list-of-rationals list-of-rationals) -> real --- The number corresponding to `a` in radix notation --- with base `b` for whole part and `c` for fraction. --- @param a real[] --- @param b real[] --- @param c real[]? --- @return real local function from_radix(a, b, c) c = c or {} local bc = {} for _, v in ipairs(b) do bc[#bc+1] = v end for _, v in ipairs(c) do bc[#bc+1] = v end local total = sum( function(i) return a[i+1] * prod( function(j) return bc[j+1] end, i, function(j) return j < #bc end ) end, 0, function(i) return i < #a end ) local denom = prod(function(j) return c[j+1] end, 0, function(j) return j < #c end) if denom == 0 then return total end return total / denom end --- Convert `x` to mixed-radix notation with bases `b` (whole) and `c` (fraction). -- @tparam number x Value to convert. -- @tparam table b Array of bases for the integer part. -- @tparam[opt] table c Array of bases for the fractional part. -- @treturn table Array of digits. function M.to_radix(x, b, c) c = c or {} if #c == 0 then if #b == 0 then return {x} else local last_b = b[#b] local prefix = {table.unpack(b, 1, #b - 1)} local head = to_radix(quotient(x, last_b), prefix) local tail = {x % last_b} for _, v in ipairs(tail) do head[#head+1] = v end return head end else local denom = prod(function(j) return c[j+1] end, 0, function(j) return j < #c end) local bc = {} for _, v in ipairs(b) do bc[#bc+1] = v end for _, v in ipairs(c) do bc[#bc+1] = v end return to_radix(x * denom, bc) end end to_radix = M.to_radix --- TYPE moment -> clock-time --- Clock time hour:minute:second from moment `tee`. --- @param tee moment --- @return clock-time local function clock_from_moment(tee) local r = to_radix(tee, {}, {24, 60, 60}) return {r[2], r[3], r[4]} -- drop the day part (first element) end --- TYPE clock-time -> time --- Time of day from `hms` = hour:minute:second. --- @param hms clock-time --- @return real local function time_from_clock(hms) return from_radix(hms, {}, {24, 60, 60}) / 24 end -- === Angles === --- TYPE (degree minute real) -> angle --- Construct an angle from degrees, arcminutes, and arcseconds. --- @param d integer --- @param m integer --- @param s real --- @return angle local function degrees_minutes_seconds(d, m, s) -- luacheck: ignore return {d, m, s} end --- TYPE angle -> list-of-reals --- List of degrees-arcminutes-arcseconds from angle `alpha` in degrees. --- @param alpha real --- @return angle local function angle_from_degrees(alpha) -- luacheck: ignore local dms = to_radix(math.abs(alpha), {}, {60, 60}) if alpha >= 0 then return dms else return {-dms[1], -dms[2], -dms[3]} end end -- === Julian Day Numbers === --- TYPE julian-day-number -> moment --- Moment of Julian Day Number `jd`. --- @param jd real --- @return moment local function moment_from_jd(jd) return jd + JD_EPOCH end local function jd_from_moment(tee) return tee - JD_EPOCH end local function fixed_from_jd(jd) return math.floor(moment_from_jd(jd)) end local function jd_from_fixed(date) return jd_from_moment(date) end -- === Modified Julian Day Numbers === --- TYPE julian-day-number -> fixed-date --- Fixed date of Modified Julian Day Number `mjd`. --- @param mjd real --- @return fixed-date local function fixed_from_mjd(mjd) return mjd + MJD_EPOCH end local function mjd_from_fixed(date) return date - MJD_EPOCH end -- === Unix time === --- TYPE second -> moment --- Fixed date from Unix second count `s`. --- @param s integer --- @return moment local function moment_from_unix(s) return UNIX_EPOCH + (s / 24 / 60 / 60) end local function unix_from_moment(tee) return 24 * 60 * 60 * (tee - UNIX_EPOCH) end -- === Intervals === --- Half-open interval [`t0`..`t1`). -- @tparam number t0 Start moment. -- @tparam number t1 End moment (excluded). -- @treturn table function M.interval(t0, t1) return {t0, t1} end local interval = M.interval --- Closed interval [`t0`..`t1`]. -- @tparam number t0 Start moment. -- @tparam number t1 End moment (included). -- @treturn table function M.interval_closed(t0, t1) return {t0, t1} end --- Start of interval `range`. -- @tparam table range Interval {t0, t1}. -- @treturn number t0. function M.begin(range) return range[1] end begin = M.begin --- End of interval `range`. Named `end_` to avoid the Lua keyword `end`. -- @tparam table range Interval {t0, t1}. -- @treturn number t1. function M.end_(range) return range[2] end end_ = M.end_ --- True if `tee` is in half-open `range` [t0..t1). -- @tparam number tee Moment to test. -- @tparam table range Interval {t0, t1}. -- @treturn boolean function M.in_range(tee, range) return begin(range) <= tee and tee < end_(range) end local in_range = M.in_range --- Those moments in list `ell` that occur in `range`. -- @tparam table ell List of moments. -- @tparam table range Interval {t0, t1}. -- @treturn table Filtered list. function M.list_range(ell, range) local result = {} for _, tee in ipairs(ell) do if in_range(tee, range) then result[#result + 1] = tee end end return result end -- List of fixed dates from a list of moments. (local helper) local function list_of_fixed_from_moments(ell) local result = {} for _, tee in ipairs(ell) do result[#result + 1] = fixed_from_moment(tee) end return result end --- List of occurrences of moment `p` of `c`-day cycle within `range`. -- `cap_delta` is the position in the cycle of RD moment 0. -- @tparam number p Position within the cycle. -- @tparam number c Cycle length in days. -- @tparam number cap_delta Cycle offset at RD 0. -- @tparam table range Interval {t0, t1}. -- @treturn table List of moments. function M.positions_in_range(p, c, cap_delta, range) local a = begin(range) local b = end_(range) local date = mod3(p - cap_delta, a, a + c) if date >= b then return {} end local result = {date} local rest = positions_in_range(p, c, cap_delta, interval(a + c, b)) for _, v in ipairs(rest) do result[#result + 1] = v end return result end positions_in_range = M.positions_in_range -- === JD / MJD / Unix conversions (public) === --- Moment from Julian Day Number `jd`. -- @tparam number jd Julian Day Number. -- @treturn number Moment. function M.moment_from_jd(jd) return moment_from_jd(jd) end --- Julian Day Number from moment `tee`. -- @tparam number tee Moment. -- @treturn number Julian Day Number. function M.jd_from_moment(tee) return jd_from_moment(tee) end --- Fixed date from Julian Day Number `jd`. -- @tparam number jd Julian Day Number. -- @treturn number Fixed date. function M.fixed_from_jd(jd) return fixed_from_jd(jd) end --- Julian Day Number from fixed `date`. -- @tparam number date Fixed date. -- @treturn number Julian Day Number. function M.jd_from_fixed(date) return jd_from_fixed(date) end --- Fixed date from Modified Julian Day Number `mjd`. -- @tparam number mjd Modified Julian Day Number. -- @treturn number Fixed date. function M.fixed_from_mjd(mjd) return fixed_from_mjd(mjd) end --- Modified Julian Day Number from fixed `date`. -- @tparam number date Fixed date. -- @treturn number Modified Julian Day Number. function M.mjd_from_fixed(date) return mjd_from_fixed(date) end --- Unix timestamp (seconds) from fixed `date`. -- @tparam number date Fixed date. -- @treturn number Unix timestamp. function M.unix_from_fixed(date) return unix_from_moment(date) end --- Fixed date from Unix timestamp `s` (seconds since 1970-01-01). -- @tparam number s Unix timestamp. -- @treturn number Fixed date. function M.fixed_from_unix(s) return math.floor(moment_from_unix(s)) end -- === Exports === --- Sentinel value used to denote nonexistent dates. M.BOGUS = BOGUS --- Residue class for Friday. M.FRIDAY = FRIDAY --- Residue class for Monday. M.MONDAY = MONDAY --- Residue class for Saturday. M.SATURDAY = SATURDAY --- Residue class for Sunday. M.SUNDAY = SUNDAY --- Residue class for Thursday. M.THURSDAY = THURSDAY --- Residue class for Tuesday. M.TUESDAY = TUESDAY --- Residue class for Wednesday. M.WEDNESDAY = WEDNESDAY --- Product of `expression(i)` for `i` from `initial` while `condition(i)` holds. -- @function prod -- @tparam func expression Function of i. -- @tparam number initial Starting value. -- @tparam func condition Predicate. -- @treturn number M.prod = prod --- Convert mixed-radix digits `a` with bases `b` (whole) and `c` (fraction) to a number. -- Inverse of `to_radix`. -- @function from_radix -- @tparam table a Digits. -- @tparam table b Bases for integer part. -- @tparam[opt] table c Bases for fractional part. -- @treturn number M.from_radix = from_radix --- Clock time {hour, minute, second} from moment `tee`. -- @function clock_from_moment -- @tparam number tee Moment. -- @treturn table {hour, minute, second} M.clock_from_moment = clock_from_moment --- Fractional day from clock time `hms` = {hour, minute, second}. -- @function time_from_clock -- @tparam table hms Clock time {hour, minute, second}. -- @treturn number Fractional day. M.time_from_clock = time_from_clock --- Moment from Unix timestamp `s` (seconds since 1970-01-01). -- @function moment_from_unix -- @tparam number s Unix timestamp. -- @treturn number Moment. M.moment_from_unix = moment_from_unix --- Unix timestamp (seconds) from moment `tee`. -- @tparam number tee Moment (fractional fixed date). -- @treturn number Unix timestamp. function M.unix_from_moment(tee) return unix_from_moment(tee) end --- List of fixed dates from a list of moments (floors each moment). -- @function list_of_fixed_from_moments -- @tparam table ell List of moments. -- @treturn table List of fixed dates. M.list_of_fixed_from_moments = list_of_fixed_from_moments return M