中國農曆很有意思,因為它並不是如英文名所表達的那種純月亮曆,而是結合了月曆和太陽曆(公曆)的一種曆法,實際上是一種日月混合曆,因此計算方法非常複雜。而更麻煩的是,幾乎所有的主流編程語言和函數庫中都沒有提供農曆的實現。因此,程序中需要計算農曆時,就很容易遇到各種各樣的問題。近期為了寫一個個人web日記系統,不得已學習了一番,並將研究結果發佈在此,以供討論和參考。
當前比較廣泛應用的是一段javascript實現(參考這裡),功能比較強大,支持閏月、節氣、公曆節日、農曆節日等。這段代碼的原始來源已不可考,不過大致可以判斷其最初應該是自台灣流出。估計當前網上的萬年曆系統應該有90%能夠找到其蹤影,其應用的廣泛程度可見一斑。但是,今年曾經有過立春日期的爭論,其罪魁禍首也正是這段代碼。根據這段代碼,2009年的立春是2月3日,而實際的天文觀測數據表明應該是2月4日。許多萬年曆由於使用了這套系統,導致出現了大量的不一致,並引起了人們的關注。由於實際時間只差不到1小時(2009年立春開始於2月4日凌晨0點40分左右),官方把這一問題解釋為「時差」。事實上,這是因為該代碼的節氣算法誤差過大造成的。
所有的曆法都需要天文觀測數據加以校正,但農曆對天文數據的依賴遠比公曆(即格里高里曆)大。因此,任何農曆計算程序都需要若干數據表才有可能完成計算,代碼非常繁雜。
首先是年基礎數據表,形式通常如下:
var lunarInfo=new Array(
0x04bd8,0x04ae0,0x0a570,0x054d5,0x0d260,
0x0d950,0x16554,0x056a0,0x09ad0,0x055d2,
0x04ae0,0x0a5b6,0x0a4d0,0x0d250,0x1d255,
0x0b540,0x0d6a0,0x0ada2,0x095b0,0x14977,
0x04970,0x0a4b0,0x0b4b5,0x06a50,0x06d40,
0x1ab54,0x02b60,0x09570,0x052f2,0x04970,
0x06566,0x0d4a0,0x0ea50,0x06e95,0x05ad0,
0x02b60,0x186e3,0x092e0,0x1c8d7,0x0c950,
0x0d4a0,0x1d8a6,0x0b550,0x056a0,0x1a5b4,
0x025d0,0x092d0,0x0d2b2,0x0a950,0x0b557,
0x06ca0,0x0b550,0x15355,0x04da0,0x0a5d0,
0x14573,0x052d0,0x0a9a8,0x0e950,0x06aa0,
0x0aea6,0x0ab50,0x04b60,0x0aae4,0x0a570,
0x05260,0x0f263,0x0d950,0x05b57,0x056a0,
0x096d0,0x04dd5,0x04ad0,0x0a4d0,0x0d4d4,
0x0d250,0x0d558,0x0b540,0x0b5a0,0x195a6,
0x095b0,0x049b0,0x0a974,0x0a4b0,0x0b27a,
0x06a50,0x06d40,0x0af46,0x0ab60,0x09570,
0x04af5,0x04970,0x064b0,0x074a3,0x0ea50,
0x06b58,0x055c0,0x0ab60,0x096d5,0x092e0,
0x0c960,0x0d954,0x0d4a0,0x0da50,0x07552,
0x056a0,0x0abb7,0x025d0,0x092d0,0x0cab5,
0x0a950,0x0b4a0,0x0baa4,0x0ad50,0x055d9,
0x04ba0,0x0a5b0,0x15176,0x052b0,0x0a930,
0x07954,0x06aa0,0x0ad50,0x05b52,0x04b60,
0x0a6e6,0x0a4e0,0x0d260,0x0ea65,0x0d530,
0x05aa0,0x076a3,0x096d0,0x04bd7,0x04ad0,
0x0a4d0,0x1d0b6,0x0d250,0x0d520,0x0dd45,
0x0b5a0,0x056d0,0x055b2,0x049b0,0x0a577,
0x0a4b0,0x0aa50,0x1b255,0x06d20,0x0ada0);
這實際上是一份農曆月份信息表,表格里的每個元素都是一個長度為20的二進制數組(用一個5位16進制數字表示)。詳細信息可以參考這裡的說明(當然,那篇文章存在不少問題)。幾乎所有的農曆計算程序都會用到這個表,不同的只是形式和範圍。這裡附上的表格包含了從公元1900年到2049年的信息。超出這個範圍的年份自然是無法計算的。
日期計算的代碼比較通用,如下所示:
//====================================== 傳回農曆 y年的總天數
function lYearDays(y) {
var i, sum = 348
for(i=0x8000; i>0x8; i>>=1) sum += (lunarInfo[y-1900] & i)? 1: 0
return(sum+leapDays(y))
}
//====================================== 傳回農曆 y年閏月的天數
function leapDays(y) {
if(leapMonth(y)) return((lunarInfo[y-1900] & 0x10000)? 30: 29)
else return(0)
}
//====================================== 傳回農曆 y年閏哪個月 1-12 , 沒閏傳回 0
function leapMonth(y) {
return(lunarInfo[y-1900] & 0xf)
}
//====================================== 傳回農曆 y年m月的總天數
function monthDays(y,m) {
return( (lunarInfo[y-1900] & (0x10000>>m))? 30: 29 )
}
//====================================== 算出農曆, 傳入日期物件, 傳回農曆日期物件
// 該物件屬性有 .year .month .day .isLeap .yearCyl .dayCyl .monCyl
function Lunar(objDate) {
var i, leap=0, temp=0
var baseDate = new Date(1900,0,31)
var offset = (objDate - baseDate)/86400000
this.dayCyl = offset + 40
this.monCyl = 14
for(i=1900; i<2050 && offset>0; i++) {
temp = lYearDays(i)
offset -= temp
this.monCyl += 12
}
if(offset<0) {
offset += temp;
i--;
this.monCyl -= 12
}
this.year = i
this.yearCyl = i-1864
leap = leapMonth(i) //閏哪個月
this.isLeap = false
for(i=1; i<13 && offset>0; i++) {
//閏月
if(leap>0 && i==(leap+1) && this.isLeap==false)
{ --i; this.isLeap = true; temp = leapDays(this.year); }
else
{ temp = monthDays(this.year, i); }
//解除閏月
if(this.isLeap==true && i==(leap+1)) this.isLeap = false
offset -= temp
if(this.isLeap == false) this.monCyl ++
}
if(offset==0 && leap>0 && i==leap+1)
if(this.isLeap)
{ this.isLeap = false; }
else
{ this.isLeap = true; --i; --this.monCyl;}
if(offset<0){ offset += temp; --i; --this.monCyl; }
this.month = i
this.day = offset + 1
}
關於這部份的計算比較好懂,也被大多人認同,我就不多解釋了。按中國的習慣,月份需要用一個字表示(如「臘月」),日期則統一按兩個漢字表示(如「初五」),調用時還需要稍微調整。調用代碼如下所示:
var numString="十一二三四五六七八九十";
var lMString="正二三四五六七八九十冬臘";
function getLunarDateStr(date){
var tY = date.getFullYear();
var tM = date.getMonth();
var tD = date.getDate();
var l = new Lunar(date);
var lM = l.month.toFixed(0);
var pre = (l.isLeap) ? '閏' : '';
var mStr = pre + lMString[lM-1] + '月';
var lD = l.day.toFixed(0) - 1;
pre = (lD <= 10) ? '初' : ((lD <= 19) ? '十' : ((lD <= 29) ? '廿' : '三'));
var dStr = pre + numString[lD % 10];
return mStr + dStr;
}
接著就是重頭戲──節氣了!!在原來的代碼中,已經是可以計算節氣了。由於節氣計算非常複雜,代碼只提供了一個函數,形式如function sTerm(year, n),用來計算某年的第n個節氣(從0小寒算起)為幾號,這也基本被認可為節氣計算的基本形式。由於每個月份有兩個節氣,計算時需要調用兩次(n和n+1),調用代碼如下:
var solarTerm = new Array("小寒","大寒","立春","雨水","驚蟄","春分","清明",
"谷雨","立夏","小滿","芒種","夏至","小暑","大暑","立秋","處暑","白露","秋分",
"寒露","霜降","立冬","小雪","大雪","冬至");
var termStr = (tD == sTerm(tY, tM*2)) ? solarTerm[tM*2] : ((tD == sTerm(tY, tM*2+1)) ? solarTerm[tM*2+1] : '');
然而,造成2009年立春計算問題的關鍵也就在這裡。原代碼的sTerm()實現存在一定誤差,雖然大多數時候沒有問題,但因為2009年立春正好出現在凌晨0時,這個誤差就導致了日期被錯算成2月3日。(其實也就若干小時,完全是可容許範圍的說。。。)更精確的算法需要引入另外的數據表格,這一工作主要來自台灣林洵賢先生的「農曆月曆&世界時間 DHTML 程式」。相關代碼(引自IdeoCal)如下所示:
var solarTermBase = new Array(4,19,3,18,4,19,4,19,4,20,4,20,6,22,6,22,6,22,7,22,6,21,6,21);
var solarTermIdx = '0123415341536789:;<9:=<>:=1>?012@015@015@015AB78CDE8CD=1FD01GH01GH01IH01IJ0KLMN;LMBEOPDQRST0RUH0RVH0RWH0RWM0XYMNZ[MB\\]PT^_ST`_WH`_WH`_WM`_WM`aYMbc[Mde]Sfe]gfh_gih_Wih_WjhaWjka[jkl[jmn]ope]qph_qrh_sth_W';
var solarTermOS = '211122112122112121222211221122122222212222222221222122222232222222222222222233223232223232222222322222112122112121222211222122222222222222222222322222112122112121222111211122122222212221222221221122122222222222222222222223222232222232222222222222112122112121122111211122122122212221222221221122122222222222222221211122112122212221222211222122222232222232222222222222112122112121111111222222112121112121111111222222111121112121111111211122112122112121122111222212111121111121111111111122112122112121122111211122112122212221222221222211111121111121111111222111111121111111111111111122112121112121111111222111111111111111111111111122111121112121111111221122122222212221222221222111011111111111111111111122111121111121111111211122112122112121122211221111011111101111111111111112111121111121111111211122112122112221222211221111011111101111111110111111111121111111111111111122112121112121122111111011111121111111111111111011111111112111111111111011111111111111111111221111011111101110111110111011011111111111111111221111011011101110111110111011011111101111111111211111001011101110111110110011011111101111111111211111001011001010111110110011011111101111111110211111001011001010111100110011011011101110111110211111001011001010011100110011001011101110111110211111001010001010011000100011001011001010111110111111001010001010011000111111111111111111111111100011001011001010111100111111001010001010000000111111000010000010000000100011001011001010011100110011001011001110111110100011001010001010011000110011001011001010111110111100000010000000000000000011001010001010011000111100000000000000000000000011001010001010000000111000000000000000000000000011001010000010000000';
//===== 某年的第n個節氣為幾日(從0小寒起算)
function sTerm(y,n) {
return(solarTermBase[n] + Math.floor( solarTermOS.charAt( ( Math.floor(solarTermIdx.charCodeAt(y-1900)) - 48) * 24 + n ) ) );
}
這種方法大幅提昇了節氣計算的準確程度,也解決了2009年立春的問題。不過實話實說,這段代碼我是完全沒看懂的。數學白痴,殘念。。。
最後,感謝那些研究出這套算法的人們,沒有他們的工作,我們在電腦上使用農曆(哪怕是不準確的)將非常麻煩。另外,再次感慨一下,希望我國的天文台能盡一下本分,拿出一個標準的農曆計算方案來。也希望我們的農曆能夠早日進入主流語言函數庫。