--- Chinese calendar conversions. -- 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-chinese -- @release 0.1 2026-07-19 local M = {} local basic = require("calendrica-basic") local gregorian = require("calendrica-gregorian") local astro = require("calendrica-astro") -- === Epoch === -- Fixed date of start of the Chinese calendar (February 15, -2636 Gregorian). local CHINESE_EPOCH = gregorian.fixed_from_gregorian( gregorian.gregorian_date(-2636, gregorian.FEBRUARY, 15) ) -- === Augury constants === -- Lichun does not occur (double-blind year). local WIDOW = 0 -- Lichun occurs once at the end. local BLIND = 1 -- Lichun occurs once at the start. local BRIGHT = 2 -- Lichun occurs twice (double-happiness). local DOUBLE_BRIGHT = 3 -- Elapsed months at start of Chinese sexagesimal month cycle. local CHINESE_MONTH_NAME_EPOCH = 57 -- RD date of start of Chinese sexagesimal day cycle. local CHINESE_DAY_NAME_EPOCH = basic.rd(45) -- === Date constructor and accessors === -- Chinese date constructor. local function chinese_date(cycle, year, month, leap, day) return {cycle, year, month, leap, day} end -- Chinese date accessors. local function chinese_cycle(date) return date[1] end local function chinese_year(date) return date[2] end local function chinese_month(date) return date[3] end local function chinese_leap(date) return date[4] end local function chinese_day(date) return date[5] end -- === Location === -- Location of Beijing; time zone varies with tee. local function chinese_location(tee) local year = gregorian.gregorian_year_from_fixed(math.floor(tee)) if year < 1929 then return astro.location( astro.angle(39, 55, 0), astro.angle(116, 25, 0), astro.mt(43.5), astro.hr(1397/180) ) else return astro.location( astro.angle(39, 55, 0), astro.angle(116, 25, 0), astro.mt(43.5), astro.hr(8) ) end end -- Universal time of midnight at start of fixed date in China. local function midnight_in_china(date) return astro.universal_from_standard(date, chinese_location(date)) end -- Moment of first time at or after tee when solar longitude will be lambda. local function chinese_solar_longitude_on_or_after(lambda, tee) local sun = astro.solar_longitude_after( lambda, astro.universal_from_standard(tee, chinese_location(tee)) ) return astro.standard_from_universal(sun, chinese_location(sun)) end -- === Solar terms === -- Last Chinese major solar term (zhongqi) before fixed date. local function current_major_solar_term(date) local s = astro.solar_longitude( astro.universal_from_standard(date, chinese_location(date)) ) return basic.amod(2 + basic.quotient(s, 30), 12) end -- Moment (in Beijing) of first Chinese major solar term on or after fixed date. local function major_solar_term_on_or_after(date) local s = astro.solar_longitude(midnight_in_china(date)) local l = (30 * math.ceil(s / 30)) % 360 return chinese_solar_longitude_on_or_after(l, date) end -- Last Chinese minor solar term (jieqi) before date. local function current_minor_solar_term(date) local s = astro.solar_longitude( astro.universal_from_standard(date, chinese_location(date)) ) return basic.amod(3 + basic.quotient(s - 15, 30), 12) end -- Moment (in Beijing) of first Chinese minor solar term on or after fixed date. local function minor_solar_term_on_or_after(date) local s = astro.solar_longitude(midnight_in_china(date)) local l = (30 * math.ceil((s - 15) / 30) + 15) % 360 return chinese_solar_longitude_on_or_after(l, date) end -- === New moon calculations === -- Fixed date (Beijing) of first new moon before fixed date. local function chinese_new_moon_before(date) local tee = astro.new_moon_before(midnight_in_china(date)) return math.floor( astro.standard_from_universal(tee, chinese_location(tee)) ) end -- Fixed date (Beijing) of first new moon on or after fixed date. local function chinese_new_moon_on_or_after(date) local tee = astro.new_moon_at_or_after(midnight_in_china(date)) return math.floor( astro.standard_from_universal(tee, chinese_location(tee)) ) end -- True if Chinese lunar month starting on date has no major solar term. local function chinese_no_major_solar_term(date) return current_major_solar_term(date) == current_major_solar_term( chinese_new_moon_on_or_after(date + 1) ) end -- === Winter solstice === -- Fixed date of winter solstice in Chinese zone on or before fixed date. local function chinese_winter_solstice_on_or_before(date) local approx = astro.estimate_prior_solar_longitude( astro.WINTER, midnight_in_china(date + 1) ) return basic.next( math.floor(approx) - 1, function(day) return astro.WINTER < astro.solar_longitude( midnight_in_china(day + 1) ) end ) end -- === New year === -- Fixed date of Chinese New Year in sui containing date. local function chinese_new_year_in_sui(date) local s1 = chinese_winter_solstice_on_or_before(date) local s2 = chinese_winter_solstice_on_or_before(s1 + 370) local m12 = chinese_new_moon_on_or_after(s1 + 1) local m13 = chinese_new_moon_on_or_after(m12 + 1) local next_m11 = chinese_new_moon_before(s2 + 1) if basic.round_half_to_even( (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH ) == 12 and (chinese_no_major_solar_term(m12) or chinese_no_major_solar_term(m13)) then return chinese_new_moon_on_or_after(m13 + 1) else return m13 end end -- Fixed date of Chinese New Year on or before fixed date. local function chinese_new_year_on_or_before(date) local new_year = chinese_new_year_in_sui(date) if date >= new_year then return new_year else return chinese_new_year_in_sui(date - 180) end end -- True if there is a leap month on or after m_prime and on or before m (Beijing). local function chinese_prior_leap_month(m_prime, m) -- luacheck: ignore return m >= m_prime and (chinese_no_major_solar_term(m) or chinese_prior_leap_month( m_prime, chinese_new_moon_before(m) )) end --- Fixed date of Chinese New Year in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.chinese_new_year(g_year) return chinese_new_year_on_or_before( gregorian.fixed_from_gregorian( gregorian.gregorian_date(g_year, gregorian.JULY, 1) ) ) end -- === Asian calendar factory === -- The Lisp source hardcodes Beijing for chinese_from_fixed / fixed_from_chinese. -- Following the calcal R package, we generalise via a location function so that -- Japanese, Korean, and Vietnamese variants share the same algorithm. local function asian_calendar(locfn) local function midnight(date) return astro.universal_from_standard(date, locfn(date)) end local function cur_major_term(date) local s = astro.solar_longitude(astro.universal_from_standard(date, locfn(date))) return basic.amod(2 + basic.quotient(s, 30), 12) end local function no_major_term(date) return cur_major_term(date) == cur_major_term( math.floor(astro.standard_from_universal( astro.new_moon_at_or_after(midnight(date + 1)), locfn(midnight(date + 1))))) end local function new_moon_before(date) local tee = astro.new_moon_before(midnight(date)) return math.floor(astro.standard_from_universal(tee, locfn(tee))) end local function new_moon_on_or_after(date) local tee = astro.new_moon_at_or_after(midnight(date)) return math.floor(astro.standard_from_universal(tee, locfn(tee))) end local function winter_solstice_on_or_before(date) local approx = astro.estimate_prior_solar_longitude( astro.WINTER, midnight(date + 1)) return basic.next( math.floor(approx) - 1, function(day) return astro.WINTER < astro.solar_longitude(midnight(day + 1)) end ) end local prior_leap_month prior_leap_month = function(m_prime, m) return m >= m_prime and (no_major_term(m) or prior_leap_month(m_prime, new_moon_before(m))) end local function new_year_on_or_before(date) local function new_year_in_sui(d) local s1 = winter_solstice_on_or_before(d) local s2 = winter_solstice_on_or_before(s1 + 370) local m12 = new_moon_on_or_after(s1 + 1) local m13 = new_moon_on_or_after(m12 + 1) local next_m11 = new_moon_before(s2 + 1) if basic.round_half_to_even( (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH) == 12 and (no_major_term(m12) or no_major_term(m13)) then return new_moon_on_or_after(m13 + 1) else return m13 end end local ny = new_year_in_sui(date) return date >= ny and ny or new_year_in_sui(date - 180) end local from_fixed, to_fixed from_fixed = function(date) local s1 = winter_solstice_on_or_before(date) local s2 = winter_solstice_on_or_before(s1 + 370) local m12 = new_moon_on_or_after(s1 + 1) local next_m11 = new_moon_before(s2 + 1) local m = new_moon_before(date + 1) local leap_year = basic.round_half_to_even( (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH) == 12 local month = basic.amod( basic.round_half_to_even((m - m12) / astro.MEAN_SYNODIC_MONTH) - (leap_year and prior_leap_month(m12, m) and 1 or 0), 12 ) local leap_month = leap_year and no_major_term(m) and not prior_leap_month(m12, new_moon_before(m)) local elapsed_years = math.floor( 1.5 - month / 12 + (date - CHINESE_EPOCH) / astro.MEAN_TROPICAL_YEAR) local cycle = 1 + basic.quotient(elapsed_years - 1, 60) local year = basic.amod(elapsed_years, 60) local day = 1 + date - m return chinese_date(cycle, year, month, leap_month, day) end to_fixed = function(c_date) local cycle = chinese_cycle(c_date) local year = chinese_year(c_date) local month = chinese_month(c_date) local leap = chinese_leap(c_date) local day = chinese_day(c_date) local mid_year = math.floor( CHINESE_EPOCH + ((cycle - 1) * 60 + (year - 1) + 1/2) * astro.MEAN_TROPICAL_YEAR) local ny = new_year_on_or_before(mid_year) local p = new_moon_on_or_after(ny + (month - 1) * 29) local d = from_fixed(p) local prior_new_moon if month == chinese_month(d) and leap == chinese_leap(d) then prior_new_moon = p else prior_new_moon = new_moon_on_or_after(p + 1) end return prior_new_moon + day - 1 end return from_fixed, to_fixed end -- === Japanese, Korean, Vietnamese locations === -- Location for Japanese calendar; varies with tee. local function japanese_location(tee) local year = gregorian.gregorian_year_from_fixed(math.floor(tee)) if year < 1888 then return astro.location( 35.7, astro.angle(139, 46, 0), astro.mt(24), astro.hr(9 + 143/450) ) else return astro.location(35, 135, astro.mt(0), astro.hr(9)) end end -- Location for Korean calendar (Seoul); varies with tee. local function korean_location(tee) local z if tee < gregorian.fixed_from_gregorian( gregorian.gregorian_date(1908, gregorian.APRIL, 1)) then z = 3809/450 elseif tee < gregorian.fixed_from_gregorian( gregorian.gregorian_date(1912, gregorian.JANUARY, 1)) then z = 8.5 elseif tee < gregorian.fixed_from_gregorian( gregorian.gregorian_date(1954, gregorian.MARCH, 21)) then z = 9 elseif tee < gregorian.fixed_from_gregorian( gregorian.gregorian_date(1961, gregorian.AUGUST, 10)) then z = 8.5 else z = 9 end return astro.location( astro.angle(37, 34, 0), astro.angle(126, 58, 0), astro.mt(0), astro.hr(z) ) end -- Equivalent Korean year to Chinese cycle and year. local function korean_year(cycle, year) return 60 * cycle + year - 364 end -- Location for Vietnamese calendar (Hanoi); varies with tee. local function vietnamese_location(tee) local z if tee < gregorian.gregorian_new_year(1968) then z = 8 else z = 7 end return astro.location( astro.angle(21, 2, 0), astro.angle(105, 51, 0), astro.mt(12), astro.hr(z) ) end -- === Conversion === local _cn_from, _cn_to = asian_calendar(chinese_location) local _jp_from, _jp_to = asian_calendar(japanese_location) local _ko_from, _ko_to = asian_calendar(korean_location) local _vn_from, _vn_to = asian_calendar(vietnamese_location) --- Chinese date {cycle, year, month, leap, day} of fixed `date`. -- @function chinese_from_fixed -- @tparam number date Fixed date. -- @treturn table {cycle, year, month, leap, day} M.chinese_from_fixed = _cn_from --- Fixed date of Chinese date `c_date`. -- @function fixed_from_chinese -- @tparam table c_date Chinese date {cycle, year, month, leap, day}. -- @treturn number Fixed date. M.fixed_from_chinese = _cn_to --- Japanese date {cycle, year, month, leap, day} of fixed `date`. -- Uses Tokyo meridian (pre-1888) or JST zone (1888+). -- @function japanese_from_fixed -- @tparam number date Fixed date. -- @treturn table {cycle, year, month, leap, day} M.japanese_from_fixed = _jp_from --- Fixed date of Japanese date `j_date`. -- @function fixed_from_japanese -- @tparam table j_date Japanese date {cycle, year, month, leap, day}. -- @treturn number Fixed date. M.fixed_from_japanese = _jp_to --- Korean date {year, month, leap, day} of fixed `date`. -- Uses Seoul city hall meridian with historically accurate time zones. -- Year is the continuous Korean year (cycle * 60 + sexagesimal_year - 364). -- @function korean_from_fixed -- @tparam number date Fixed date. -- @treturn table {year, month, leap, day} function M.korean_from_fixed(date) local d = _ko_from(date) return {korean_year(chinese_cycle(d), chinese_year(d)), chinese_month(d), chinese_leap(d), chinese_day(d)} end --- Fixed date of Korean date `k_date`. -- @function fixed_from_korean -- @tparam table k_date Korean date {year, month, leap, day}. -- @treturn number Fixed date. function M.fixed_from_korean(k_date) local year = k_date[1] local cycle = math.floor((year + 364) / 60) local y = basic.amod(year + 364, 60) return _ko_to(chinese_date(cycle, y, k_date[2], k_date[3], k_date[4])) end --- Vietnamese date {cycle, year, month, leap, day} of fixed `date`. -- Uses Hanoi meridian (UTC+8 before 1968, UTC+7 after). -- @function vietnamese_from_fixed -- @tparam number date Fixed date. -- @treturn table {cycle, year, month, leap, day} M.vietnamese_from_fixed = _vn_from --- Fixed date of Vietnamese date `v_date`. -- @function fixed_from_vietnamese -- @tparam table v_date Vietnamese date {cycle, year, month, leap, day}. -- @treturn number Fixed date. M.fixed_from_vietnamese = _vn_to -- === Sexagesimal names === -- Chinese sexagesimal name constructor. local function chinese_name(stem, branch) return {stem, branch} end -- Chinese name accessors. local function chinese_stem(name) return name[1] end local function chinese_branch(name) return name[2] end -- The n-th name of the Chinese sexagesimal cycle. local function chinese_sexagesimal_name(n) return chinese_name(basic.amod(n, 10), basic.amod(n, 12)) end -- Number of names from c_name1 to the next occurrence of c_name2. local function chinese_name_difference(c_name1, c_name2) local stem1 = chinese_stem(c_name1) local stem2 = chinese_stem(c_name2) local branch1 = chinese_branch(c_name1) local branch2 = chinese_branch(c_name2) local stem_difference = stem2 - stem1 local branch_difference = branch2 - branch1 return basic.amod( stem_difference + 25 * (branch_difference - stem_difference), 60 ) end -- Sexagesimal name for Chinese year of any cycle. local function chinese_year_name(year) return chinese_sexagesimal_name(year) end -- Sexagesimal name for month of Chinese year. local function chinese_month_name(month, year) local elapsed_months = 12 * (year - 1) + (month - 1) return chinese_sexagesimal_name(elapsed_months - CHINESE_MONTH_NAME_EPOCH) end -- Chinese sexagesimal name for date. local function chinese_day_name(date) return chinese_sexagesimal_name(date - CHINESE_DAY_NAME_EPOCH) end -- Latest date on or before fixed date with the given Chinese name. local function chinese_day_name_on_or_before(name, date) return basic.mod3( chinese_name_difference(chinese_day_name(0), name), date, date - 60 ) end -- === Chinese holidays and special dates === --- Fixed date of the Dragon Festival (Double Fifth) in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.dragon_festival(g_year) local elapsed_years = 1 + g_year - gregorian.gregorian_year_from_fixed(CHINESE_EPOCH) local cycle = 1 + basic.quotient(elapsed_years - 1, 60) local year = basic.amod(elapsed_years, 60) return M.fixed_from_chinese(chinese_date(cycle, year, 5, false, 5)) end --- Fixed date of Qingming (Clear and Bright) in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.qing_ming(g_year) return math.floor( minor_solar_term_on_or_after( gregorian.fixed_from_gregorian( gregorian.gregorian_date(g_year, gregorian.MARCH, 30) ) ) ) end -- Age at fixed date given Chinese birthdate; bogus if date is before birthdate. local function chinese_age(birthdate, date) local today = M.chinese_from_fixed(date) if date >= M.fixed_from_chinese(birthdate) then return 60 * (chinese_cycle(today) - chinese_cycle(birthdate)) + chinese_year(today) - chinese_year(birthdate) + 1 else return basic.BOGUS end end -- Marriage augury type of Chinese year in cycle. local function chinese_year_marriage_augury(cycle, year) local new_year = M.fixed_from_chinese( chinese_date(cycle, year, 1, false, 1) ) local c = year == 60 and cycle + 1 or cycle local y = year == 60 and 1 or year + 1 local next_new_year = M.fixed_from_chinese( chinese_date(c, y, 1, false, 1) ) local first_minor_term = current_minor_solar_term(new_year) local next_first_minor_term = current_minor_solar_term(next_new_year) if first_minor_term == 1 and next_first_minor_term == 12 then return WIDOW elseif first_minor_term == 1 and next_first_minor_term ~= 12 then return BLIND elseif first_minor_term ~= 1 and next_first_minor_term == 12 then return BRIGHT else return DOUBLE_BRIGHT end end -- === Exports === --- Major solar term (zhongqi, 1..12) in effect on fixed `date`. -- @function current_major_solar_term -- @tparam number date Fixed date. -- @treturn number Solar term index (1..12). M.current_major_solar_term = current_major_solar_term --- Moment (Beijing time) of first major solar term on or after fixed `date`. -- @function major_solar_term_on_or_after -- @tparam number date Fixed date. -- @treturn number Moment. M.major_solar_term_on_or_after = major_solar_term_on_or_after --- Minor solar term (jieqi, 1..12) in effect on fixed `date`. -- @function current_minor_solar_term -- @tparam number date Fixed date. -- @treturn number Solar term index (1..12). M.current_minor_solar_term = current_minor_solar_term --- Moment (Beijing time) of first minor solar term on or after fixed `date`. -- @function minor_solar_term_on_or_after -- @tparam number date Fixed date. -- @treturn number Moment. M.minor_solar_term_on_or_after = minor_solar_term_on_or_after --- Sexagesimal name {stem, branch} for Chinese year `year` of any cycle. -- @function chinese_year_name -- @tparam number year Year within cycle (1..60). -- @treturn table {stem, branch} M.chinese_year_name = chinese_year_name --- Sexagesimal name {stem, branch} for `month` of Chinese `year`. -- @function chinese_month_name -- @tparam number month Month number. -- @tparam number year Year within cycle. -- @treturn table {stem, branch} M.chinese_month_name = chinese_month_name --- Chinese sexagesimal day-name {stem, branch} for fixed `date`. -- @function chinese_day_name -- @tparam number date Fixed date. -- @treturn table {stem, branch} M.chinese_day_name = chinese_day_name --- Latest fixed date on or before `date` with the given Chinese sexagesimal `name`. -- @function chinese_day_name_on_or_before -- @tparam table name Sexagesimal name {stem, branch}. -- @tparam number date Fixed date. -- @treturn number Fixed date. M.chinese_day_name_on_or_before = chinese_day_name_on_or_before --- The n-th name of the Chinese sexagesimal cycle. -- @function chinese_sexagesimal_name -- @tparam number n Position in cycle. -- @treturn table {stem, branch} M.chinese_sexagesimal_name = chinese_sexagesimal_name --- Number of names from `c_name1` to the next occurrence of `c_name2` in the sexagesimal cycle. -- @function chinese_name_difference -- @tparam table c_name1 Sexagesimal name {stem, branch}. -- @tparam table c_name2 Sexagesimal name {stem, branch}. -- @treturn number Count (1..60). M.chinese_name_difference = chinese_name_difference --- Age in Chinese reckoning at fixed `date` given Chinese `birthdate`. -- Returns BOGUS if date is before birthdate. -- @function chinese_age -- @tparam table birthdate Chinese date {cycle, year, month, leap, day}. -- @tparam number date Fixed date. -- @treturn number Age (or BOGUS). M.chinese_age = chinese_age --- Marriage augury type for Chinese year `year` of `cycle`. -- Returns one of WIDOW, BLIND, BRIGHT, or DOUBLE_BRIGHT. -- @function chinese_year_marriage_augury -- @tparam number cycle Cycle number. -- @tparam number year Year within cycle (1..60). -- @treturn number Augury constant. M.chinese_year_marriage_augury = chinese_year_marriage_augury --- Year type: no major solar term in either adjacent month (most auspicious). M.WIDOW = WIDOW --- Year type: no major solar term in the current month. M.BLIND = BLIND --- Year type: major solar term in the current month only. M.BRIGHT = BRIGHT --- Year type: major solar term in both the current and an adjacent month. M.DOUBLE_BRIGHT = DOUBLE_BRIGHT return M