모듈:음양력

이 모듈에 대한 설명문서는 모듈:음양력/설명문서에서 만들 수 있습니다

--[[
Module:음양력  (Lunisolar <-> Gregorian calendar converter)

천문 계산(삭·중기)으로 태음태양력(음력)과 그레고리력(양력)을 변환한다.
한국천문연구원(KASI)·중국 농력과 동일한 규칙을 사용한다.

  * 달의 시작 = 삭(New Moon)이 드는 날 (지방시 기준 자정~자정)
  * 동지가 든 달 = 11월
  * 동지에서 다음 동지까지 13삭망월이면 윤달이 있는 해이며,
    중기(태양황경 30°의 배수)가 들지 않는 첫 번째 달이 윤달 (무중치윤법)

표준시: 기본 UTC+9 (한국). frame 인자 tz 또는 setTimezone()으로 변경 가능.
]]

local p = {}

local floor, sin, abs = math.floor, math.sin, math.abs
local RAD = math.pi / 180

local FIXED_TZ = nil
local MIN_YEAR, MAX_YEAR = 1600, 2200

-- 간지 ---------------------------------------------------------------------
local STEM   = {'갑','을','병','정','무','기','경','신','임','계'}
local BRANCH = {'자','축','인','묘','진','사','오','미','신','유','술','해'}
local ZODIAC = {'쥐','소','호랑이','토끼','용','뱀','말','양','원숭이','닭','개','돼지'}

-- 유틸 ---------------------------------------------------------------------
local function mod360(x) return x - 360 * floor(x / 360) end

local function norm180(x)  -- -180 ~ +180
	x = mod360(x)
	if x > 180 then x = x - 360 end
	return x
end

local function gToJDN(y, m, d)
	local a = floor((14 - m) / 12)
	local yy = y + 4800 - a
	local mm = m + 12 * a - 3
	return d + floor((153 * mm + 2) / 5) + 365 * yy
		+ floor(yy / 4) - floor(yy / 100) + floor(yy / 400) - 32045
end

local function jdnToG(jdn)
	local a = jdn + 32044
	local b = floor((4 * a + 3) / 146097)
	local c = a - floor(146097 * b / 4)
	local dd = floor((4 * c + 3) / 1461)
	local e = c - floor(1461 * dd / 4)
	local mm = floor((5 * e + 2) / 153)
	local day = e - floor((153 * mm + 2) / 5) + 1
	local month = mm + 3 - 12 * floor(mm / 10)
	local year = 100 * b + dd - 4800 + floor(mm / 10)
	return year, month, day
end

local function deltaT(year)
	local t, u
	if year < 1600 then
		u = (year - 1820) / 100
		return -20 + 32 * u * u
	elseif year < 1700 then
		t = year - 1600
		return 120 - 0.9808 * t - 0.01532 * t ^ 2 + t ^ 3 / 7129
	elseif year < 1800 then
		t = year - 1700
		return 8.83 + 0.1603 * t - 0.0059285 * t ^ 2 + 0.00013336 * t ^ 3 - t ^ 4 / 1174000
	elseif year < 1860 then
		t = year - 1800
		return 13.72 - 0.332447 * t + 0.0068612 * t ^ 2 + 0.0041116 * t ^ 3
			- 0.00037436 * t ^ 4 + 0.0000121272 * t ^ 5 - 0.0000001699 * t ^ 6
			+ 0.000000000875 * t ^ 7
	elseif year < 1900 then
		t = year - 1860
		return 7.62 + 0.5737 * t - 0.251754 * t ^ 2 + 0.01680668 * t ^ 3
			- 0.0004473624 * t ^ 4 + t ^ 5 / 233174
	elseif year < 1920 then
		t = year - 1900
		return -2.79 + 1.494119 * t - 0.0598939 * t ^ 2 + 0.0061966 * t ^ 3 - 0.000197 * t ^ 4
	elseif year < 1941 then
		t = year - 1920
		return 21.20 + 0.84493 * t - 0.076100 * t ^ 2 + 0.0020936 * t ^ 3
	elseif year < 1961 then
		t = year - 1950
		return 29.07 + 0.407 * t - t ^ 2 / 233 + t ^ 3 / 2547
	elseif year < 1986 then
		t = year - 1975
		return 45.45 + 1.067 * t - t ^ 2 / 260 - t ^ 3 / 718
	elseif year < 2005 then
		t = year - 2000
		return 63.86 + 0.3345 * t - 0.060374 * t ^ 2 + 0.0017275 * t ^ 3
			+ 0.000651814 * t ^ 4 + 0.00002373599 * t ^ 5
	elseif year < 2050 then
		t = year - 2000
		return 62.92 + 0.32217 * t + 0.005589 * t ^ 2
	elseif year < 2150 then
		return -20 + 32 * ((year - 1820) / 100) ^ 2 - 0.5628 * (2150 - year)
	else
		u = (year - 1820) / 100
		return -20 + 32 * u * u
	end
end

local function deltaTdays(jde)
	local year = 2000 + (jde - 2451545) / 365.25
	return deltaT(year) / 86400
end

local TZ_EPOCHS = {
	{gToJDN(1912,  1,  1) - 0.5, 8   / 24},   -- ~1911
	{gToJDN(1954,  3, 21) - 0.5, 9   / 24},   -- 1912~1954-03-20
	{gToJDN(1961,  8, 10) - 0.5, 8.5 / 24},   -- 1954-03-21~1961-08-09
}

local function tzAt(jd)
	if FIXED_TZ then return FIXED_TZ end
	for i = 1, #TZ_EPOCHS do
		if jd < TZ_EPOCHS[i][1] then return TZ_EPOCHS[i][2] end
	end
	return 9 / 24
end

local function jdeToLocalDay(jde)
	local ut = jde - deltaTdays(jde)
	return floor(ut + 0.5 + tzAt(ut))
end

local function sunLongitude(jde)
	local T = (jde - 2451545) / 36525
	local L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T
	local M = 357.52911 + 35999.05029 * T - 0.0001537 * T * T
	local Mr = M * RAD
	local C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * sin(Mr)
		+ (0.019993 - 0.000101 * T) * sin(2 * Mr)
		+ 0.000289 * sin(3 * Mr)
	local theta = L0 + C
	local omega = 125.04 - 1934.136 * T
	return mod360(theta - 0.00569 - 0.00478 * sin(omega * RAD))
end

local function newMoonJDE(k)
	local T = k / 1236.85
	local T2, T3, T4 = T * T, T * T * T, T * T * T * T
	local jde = 2451550.09766 + 29.530588861 * k
		+ 0.00015437 * T2 - 0.000000150 * T3 + 0.00000000073 * T4
	local E = 1 - 0.002516 * T - 0.0000074 * T2
	local M  = (2.5534 + 29.10535670 * k - 0.0000014 * T2 - 0.00000011 * T3) * RAD
	local Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T2
		+ 0.00001238 * T3 - 0.000000058 * T4) * RAD
	local F  = (160.7108 + 390.67050284 * k - 0.0016118 * T2
		- 0.00000227 * T3 + 0.000000011 * T4) * RAD
	local Om = (124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3) * RAD

	jde = jde
		- 0.40720 * sin(Mp)
		+ 0.17241 * E * sin(M)
		+ 0.01608 * sin(2 * Mp)
		+ 0.01039 * sin(2 * F)
		+ 0.00739 * E * sin(Mp - M)
		- 0.00514 * E * sin(Mp + M)
		+ 0.00208 * E * E * sin(2 * M)
		- 0.00111 * sin(Mp - 2 * F)
		- 0.00057 * sin(Mp + 2 * F)
		+ 0.00056 * E * sin(2 * Mp + M)
		- 0.00042 * sin(3 * Mp)
		+ 0.00042 * E * sin(M + 2 * F)
		+ 0.00038 * E * sin(M - 2 * F)
		- 0.00024 * E * sin(2 * Mp - M)
		- 0.00017 * sin(Om)
		- 0.00007 * sin(Mp + 2 * M)
		+ 0.00004 * sin(2 * Mp - 2 * F)
		+ 0.00004 * sin(3 * M)
		+ 0.00003 * sin(Mp + M - 2 * F)
		+ 0.00003 * sin(2 * Mp + 2 * F)
		- 0.00003 * sin(Mp + M + 2 * F)
		+ 0.00003 * sin(Mp - M + 2 * F)
		- 0.00002 * sin(Mp - M - 2 * F)
		- 0.00002 * sin(3 * Mp + M)
		+ 0.00002 * sin(4 * Mp)

	local A = {
		{299.77 + 0.107408 * k - 0.009173 * T2, 0.000325},
		{251.88 + 0.016321 * k, 0.000165},
		{251.83 + 26.651886 * k, 0.000164},
		{349.42 + 36.412478 * k, 0.000126},
		{ 84.66 + 18.206239 * k, 0.000110},
		{141.74 + 53.303771 * k, 0.000062},
		{207.14 +  2.453732 * k, 0.000060},
		{154.84 +  7.306860 * k, 0.000056},
		{ 34.52 + 27.261239 * k, 0.000047},
		{207.19 +  0.121824 * k, 0.000042},
		{291.34 +  1.844379 * k, 0.000040},
		{161.72 + 24.198154 * k, 0.000037},
		{239.56 + 25.513099 * k, 0.000035},
		{331.55 +  3.592518 * k, 0.000023},
	}
	for i = 1, #A do
		jde = jde + A[i][2] * sin(A[i][1] * RAD)
	end
	return jde
end

local function solarTermJDE(jde0, target)
	local jde = jde0
	for _ = 1, 8 do
		local d = norm180(target - sunLongitude(jde))
		if abs(d) < 1e-7 then break end
		jde = jde + d * 365.2422 / 360
	end
	return jde
end

local function newMoonDay(k) return jdeToLocalDay(newMoonJDE(k)) end

local function kOnOrBefore(day)
	local k = floor((day - 2451550.09766) / 29.530588861)
	while newMoonDay(k) > day do k = k - 1 end
	while newMoonDay(k + 1) <= day do k = k + 1 end
	return k
end

local function winterSolsticeDay(year)
	return jdeToLocalDay(solarTermJDE(gToJDN(year, 12, 21), 270))
end

local suiCache = {}

local function buildSui(year)
	if suiCache[year] then return suiCache[year] end

	local ws  = winterSolsticeDay(year)
	local k0  = kOnOrBefore(ws)
	local k1  = kOnOrBefore(winterSolsticeDay(year + 1))
	local n   = k1 - k0                     -- 12 또는 13

	local starts = {}
	for i = 0, n do starts[i] = newMoonDay(k0 + i) end

	local leapIndex = -1
	if n == 13 then
		local terms = {}
		local base = solarTermJDE(gToJDN(year, 12, 21), 270)
		for j = 0, 13 do
			terms[j] = jdeToLocalDay(solarTermJDE(base + 30.44 * j, mod360(270 + 30 * j)))
		end
		for i = 1, n - 1 do
			local has = false
			for j = 0, 13 do
				if terms[j] >= starts[i] and terms[j] < starts[i + 1] then has = true break end
			end
			if not has then leapIndex = i break end
		end
	end

	local months = {}
	local num, ly = 11, year
	for i = 0, n - 1 do
		local m = {
			start = starts[i],
			days  = starts[i + 1] - starts[i],
			index = i,
		}
		if i == leapIndex then
			m.num, m.leap, m.year = months[i - 1].num, true, months[i - 1].year
		else
			if num == 1 then ly = year + 1 end
			m.num, m.leap, m.year = num, false, ly
			num = num % 12 + 1
		end
		months[i] = m
	end

	local sui = {year = year, months = months, count = n,
		first = starts[0], last = starts[n] - 1, nextStart = starts[n]}
	suiCache[year] = sui
	return sui
end

local function solarToLunar(y, m, d)
	if y < MIN_YEAR or y > MAX_YEAR then return nil, '지원 범위(' .. MIN_YEAR .. '~' .. MAX_YEAR .. '년)를 벗어났습니다' end
	local jdn = gToJDN(y, m, d)
	local sui = buildSui(y)
	if jdn < sui.first then sui = buildSui(y - 1) end
	if jdn >= sui.nextStart then sui = buildSui(y + 1) end
	for i = 0, sui.count - 1 do
		local mo = sui.months[i]
		if jdn >= mo.start and jdn < mo.start + mo.days then
			return {
				year  = mo.year,
				month = mo.num,
				day   = jdn - mo.start + 1,
				leap  = mo.leap,
				days  = mo.days,
				jdn   = jdn,
			}
		end
	end
	return nil, '변환 실패'
end

local function lunarToSolar(ly, lm, ld, leap)
	if ly < MIN_YEAR or ly > MAX_YEAR then return nil, '지원 범위를 벗어났습니다' end
	if lm < 1 or lm > 12 or ld < 1 or ld > 30 then return nil, '잘못된 음력 날짜입니다' end
	local sui = buildSui(lm >= 11 and ly or ly - 1)
	for i = 0, sui.count - 1 do
		local mo = sui.months[i]
		if mo.year == ly and mo.num == lm and (not mo.leap) == (not leap) then
			if ld > mo.days then
				return string.format('변환 실패')
			end
			local yy, mm, dd = jdnToG(mo.start + ld - 1)
			return {year = yy, month = mm, day = dd, jdn = mo.start + ld - 1}
		end
	end
	if leap then
		return nil, string.format('음력 %d년에는 윤%d월이 없습니다', ly, lm)
	end
	return nil, '변환 실패'
end

local function ganzhi(idx)
	idx = idx % 60
	return STEM[idx % 10 + 1] .. BRANCH[idx % 12 + 1]
end

local function yearGanzhi(ly) return ganzhi((ly - 1984) % 60) end
local function dayGanzhi(jdn) return ganzhi((jdn + 49) % 60) end
local function zodiacOf(ly) return ZODIAC[(ly - 1984) % 12 + 1] end

p._solarToLunar = solarToLunar
p._lunarToSolar = lunarToSolar
p._buildSui     = buildSui
p._ganzhi       = { year = yearGanzhi, day = dayGanzhi, zodiac = zodiacOf }
p._toJDN        = gToJDN
p._fromJDN      = jdnToG
function p._setTimezone(hours)
	FIXED_TZ = hours and (hours / 24) or nil
	suiCache = {}
end

local function args(frame)
	local a = {}
	local parent = frame.getParent and frame:getParent()
	if parent then for k, v in pairs(parent.args) do a[k] = v end end
	for k, v in pairs(frame.args) do a[k] = v end
	return a
end

local function num(v)
	if v == nil then return nil end
	v = tostring(v):gsub('^%s+', ''):gsub('%s+$', '')
	return tonumber(v)
end

local function applyTZ(a)
	local want = a.tz and num(a.tz) or nil
	local cur = FIXED_TZ and FIXED_TZ * 24 or nil
	if want ~= cur then p._setTimezone(want) end
end

local function err(msg)
	return '<span class="error">' .. msg .. '</span>'
end

local function fmt(pattern, t, extra)
	local map = {
		['Y'] = t.year, ['n'] = t.month, ['j'] = t.day,
		['m'] = t.month,
		['d'] = t.day,
		['L'] = extra and extra.leapMark or '',
	}
	return (pattern:gsub('%%(%a)', function(c) return tostring(map[c] or '%' .. c) end))
end

function p.toLunar(frame)
	local a = args(frame)
	local y, m, d = num(a[1]), num(a[2]), num(a[3])
	if not (y and m and d) then return err('연·월·일을 입력하세요') end
	applyTZ(a)

	local r, e = solarToLunar(y, m, d)
	if not r then return err(e) end

	local leapMark = r.leap and (a.leaptext or '윤') or ''
	if a.format then
		return fmt(a.format, r, {leapMark = leapMark})
	end
	return string.format('%d년 %s%d월 %d일', r.year, leapMark, r.month, r.day)
end

function p.toSolar(frame)
	local a = args(frame)
	local y, m, d = num(a[1]), num(a[2]), num(a[3])
	if not (y and m and d) then return err('연·월·일을 입력하세요') end
	applyTZ(a)
	local leap = a.leap and a.leap ~= '' and a.leap ~= '0' and a.leap ~= 'no'

	local r, e = lunarToSolar(y, m, d, leap)
	if not r then return err(e) end

	if a.format then return fmt(a.format, r) end
	return string.format('%d년 %d월 %d일', r.year, r.month, r.day)
end

function p.leapMonth(frame)
	local a = args(frame)
	local ly = num(a[1])
	if not ly then return err('연도를 입력하세요') end
	for _, y in ipairs({ly - 1, ly}) do
		local sui = buildSui(y)
		for i = 0, sui.count - 1 do
			local mo = sui.months[i]
			if mo.leap and mo.year == ly then return tostring(mo.num) end
		end
	end
	return ''
end

function p.ganzhi(frame)
	local a = args(frame)
	local y, m, d = num(a[1]), num(a[2]), num(a[3])
	if not y then return err('연도를 입력하세요') end
	if m and d then
		return dayGanzhi(gToJDN(y, m, d))
	end
	return yearGanzhi(y)
end

function p.zodiac(frame)
	local y = num(args(frame)[1])
	if not y then return err('연도를 입력하세요') end
	return zodiacOf(y)
end

return p