From: Adrian Iain Lam Date: Sat, 1 Jun 2019 09:07:19 +0000 (+0100) Subject: Initial commit X-Git-Url: https://adrianiainlam.tk/git/?p=LunarCalendarPy.git;a=commitdiff_plain;h=e0750e842201b71c37fdb55a6d6f84bfb39d2c5f Initial commit --- e0750e842201b71c37fdb55a6d6f84bfb39d2c5f diff --git a/LunarCalendar.py b/LunarCalendar.py new file mode 100644 index 0000000..1ff374e --- /dev/null +++ b/LunarCalendar.py @@ -0,0 +1,381 @@ +import csv +import datetime +import os + + +class LunarCalendar: + """ + LunarCalendar by Adrian I. Lam (2019), + a Python 3 port of the JavaScript LunarCalendar by zzyss86, + available at . + + This is only a partial port. I have ported + LunarCalendar.solarToLunar(year, month, day) and + LunarCalendar.lunarToSolar(year, month, day) only, + and have ignored everything related to holidays. + + Since the original author did not specify any license, I will + also not license this script. This script comes with no warranties, + expressed or implied. In particular, the warranties of merchantability, + fitness for a particular purpose and noninfringement are disclaimed. + """ + + def __formatDayD4(self, month, day): + month = month + 1 + month = '0' + str(month) if month < 10 else str(month) + day = '0' + str(day) if day < 10 else str(day) + return 'd' + month + day + + __minYear = 1890 + __maxYear = 2100 + + __heavenlyStems = ['甲', '乙', '丙', '丁', '戊', + '己', '庚', '辛', '壬', '癸'] # 天干 + __earthlyBranches = ['子', '丑', '寅', '卯', '辰', '巳', + '午', '未', '申', '酉', '戌', '亥'] # 地支 + __zodiac = ['鼠', '牛', '虎', '兔', '龍', '蛇', + '馬', '羊', '猴', '雞', '狗', '豬'] # 對應地支十二生肖 + __solarTerm = ['小寒', '大寒', '立春', '雨水', '驚蟄', '春分', + '清明', '穀雨', '立夏', '小滿', '芒種', '夏至', + '小暑', '大暑', '立秋', '處暑', '白露', '秋分', + '寒露', '霜降', '立冬', '小雪', '大雪', '冬至'] # 二十四節氣 + __monthCn = ['正', '二', '三', '四', '五', '六', + '七', '八', '九', '十', '十一', '十二'] + __dateCn = ['初一', '初二', '初三', '初四', '初五', '初六', + '初七', '初八', '初九', '初十', '十一', '十二', + '十三', '十四', '十五', '十六', '十七', '十八', + '十九', '二十', '廿一', '廿二', '廿三', '廿四', + '廿五', '廿六', '廿七', '廿八', '廿九', '三十', '卅一'] + + # 1890 - 2100 年的農曆數據 + # 數據格式:[0,2,9,21936] + # [閏月所在月,0為沒有閏月; + # *正月初一對應公曆月; + # *正月初一對應公曆日; + # *農曆每月的天數的數組(需轉換為二進制,得到每月大小, + # 0=小月(29日),1=大月(30日));] + __lunarInfo = [] + __dir = os.path.dirname(os.path.realpath(__file__)) + with open(os.path.join(__dir, 'lunarInfo.csv')) as __lunarInfoCSV: + for __row in csv.reader(__lunarInfoCSV): + __lunarInfo.append([int(x) for x in __row]) + + # 二十四節氣數據,節氣點時間(單位是分鐘) + # 從0小寒起算 + __termInfo = [0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, + 173149, 195551, 218072, 240693, 263343, 285989, 308563, + 331033, 353350, 375494, 397447, 419210, 440795, 462224, + 483532, 504758] + + def __init__(self): + self.__cache = {} + + def __getLunarLeapYear(self, year): + """ + 判斷農曆年閏月數 + @param {Number} year 農曆年 + return 閏月數(月份從1開始) + """ + yearData = self.__lunarInfo[year - self.__minYear] + return yearData[0] + + def __getLunarYearDays(self, year): + """ + 獲取農曆年份一年的每月的天數及一年的總天數 + @param {Number} year 農曆年 + """ + yearData = self.__lunarInfo[year - self.__minYear] + leapMonth = yearData[0] + monthData = yearData[3] + + monthDataArr = [] + for i in range(15, -1, -1): + monthDataArr.append((monthData & (1 << i)) >> i) + + numMonthsInYear = 13 if leapMonth else 12 + yearDays = 0 + monthDays = [] + for i in range(numMonthsInYear): + if monthDataArr[i] == 0: + yearDays += 29 + monthDays.append(29) + else: + yearDays += 30 + monthDays.append(30) + + return { + 'yearDays': yearDays, + 'monthDays': monthDays + } + + def __getLunarDateByBetween(self, year, between): + """ + 通過間隔天數查找農曆日期 + @param {Number} year,between 農曆年,間隔天數 + """ + lunarYearDays = self.__getLunarYearDays(year) + end = between if between > 0 else lunarYearDays['yearDays'] + between + monthDays = lunarYearDays['monthDays'] + tempDays = 0 + month = 0 + for i in range(len(monthDays)): + tempDays += monthDays[i] + if tempDays > end: + month = i + tempDays = tempDays - monthDays[i] + break + + return [year, month, end - tempDays + 1] + + def __getLunarByBetween(self, year, month, day): + """ + 根據距離正月初一的天數計算農曆日期 + @param {Number} year 公曆年,月,日 + """ + yearData = self.__lunarInfo[year - self.__minYear] + zenMonth = yearData[1] + zenDay = yearData[2] + between = self.__getDaysBetweenSolar(year, zenMonth - 1, zenDay, + year, month, day) + if between == 0: + return [year, 0, 1] + else: + lunarYear = year if between > 0 else year - 1 + return self.__getLunarDateByBetween(lunarYear, between) + + def __getDaysBetweenSolar(self, year, month, day, year1, month1, day1): + """ + 兩個公曆日期之間的天數 + """ + # https://stackoverflow.com/a/151211 + d0 = datetime.date(year, month + 1, day) + d1 = datetime.date(year1, month1 + 1, day1) + delta = d1 - d0 + return delta.days + + def __getDaysBetweenZheng(self, year, month, day): + """ + 計算農曆日期離正月初一有多少天 + @param {Number} year,month,day 農年,月(0-12,有閏月),日 + """ + lunarYearDays = self.__getLunarYearDays(year) + monthDays = lunarYearDays['monthDays'] + days = 0 + for i in range(len(monthDays)): + if i < month: + days += monthDays[i] + else: + break + return days + day - 1 + + def __getTerm(self, y, n): + """ + 某年的第n個節氣為幾日 + 31556925974.7為地球公轉週期,是毫秒 + 1890年的正小寒點:01-05 16:02:31,1890年為基準點 + @param {Number} y 公曆年 + @param {Number} n 第幾個節氣,從0小寒起算 + 由於農曆24節氣交節時刻採用近似算法,可能存在少量誤差(30分鐘內) + """ + offsetms = 31556925974.7 * (y - 1890) + self.__termInfo[n] * 60000 + offDate = datetime.datetime( + 1890, 1, 5, 16, 2, 31, + tzinfo=datetime.timezone.utc + ) + datetime.timedelta(milliseconds=offsetms) + return offDate.day + + def __getYearTerm(self, year): + """ + 獲取公曆年一年的二十四節氣 + 返回key:日期,value:節氣中文名 + """ + res = {} + month = 0 + for i in range(24): + day = self.__getTerm(year, i) + if i % 2 == 0: + month += 1 + res[self.__formatDayD4(month - 1, day)] = self.__solarTerm[i] + return res + + def __getYearZodiac(self, year): + """ + 獲取生肖 + @param {Number} year 干支所在年(默認以立春前的公曆年作為基數) + """ + num = year - 1890 + 25 # 參考干支紀年的計算,生肖對應地支 + return self.__zodiac[num % 12] + + def __cyclical(self, num): + """ + 計算天干地支 + @param {Number} num 60進制中的位置(把60個天干地支,當成一個60進制的數) + """ + return ( + self.__heavenlyStems[num % 10] + + self.__earthlyBranches[num % 12] + ) + + def __getLunarYearName(self, year, offset=0): + """ + 獲取干支紀年 + @param {Number} year 干支所在年 + @param {Number} offset 偏移量,默認為0,便於查詢一個年跨兩個干支紀年(以立春為分界線) + """ + # 1890年1月小寒(小寒一般是1月5或6日)以前為己丑年,在60進制中排25 + return self.__cyclical(year - 1890 + 25 + offset) + + def __getLunarMonthName(self, year, month, offset=0): + """ + 獲取干支紀月 + @param {Number} year,month 公曆年,干支所在月 + @param {Number} offset 偏移量,默認為0,便於查詢一個月跨兩個干支紀月(有立春的2月) + """ + # 1890年1月小寒以前為丙子月,在60進制中排12 + return self.__cyclical((year - 1890) * 12 + month + 12 + offset) + + def __getLunarDayName(self, year, month, day): + """ + 獲取干支紀日 + @param {Number} year,month,day 公曆年,月,日 + """ + # 當日與189​​0/1/1 相差天數 + # 1890/1/1與1970/1/1 相差29219日, 1890/1/1 日柱為壬午日(60進制18) + dayCyclical = ( + datetime.date(year, month + 1, day) + + datetime.timedelta(days=29219+18) - + datetime.date(1970, 1, 1) + ) + + return self.__cyclical(dayCyclical.days) + + def __getSolarMonthDays(self, year, month): + """ + 獲取公曆月份的天數 + @param {Number} year 公曆年 + @param {Number} month 公曆月 + """ + monthDays = [31, 29 if self.__isLeapYear(year) else 28, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return monthDays[month] + + def __isLeapYear(self, year): + """ + 判斷公曆年是否是閏年 + @param {Number} year 公曆年 + """ + return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 + + def __formatDate(self, year=None, month=None, day=None, _minYear=None): + """ + 統一日期輸入參數(輸入月份從1開始,內部月份統一從0開始) + """ + now = datetime.datetime.now() + year = int(year) if year is not None else now.year + month = int(month) - 1 if month is not None else now.month - 1 + day = int(day) if day is not None else now.day + if year < (_minYear if _minYear is not None + else self.__minYear + 1) or year > self.__maxYear: + return { + 'error': 100, + 'msg': 'Year out of range' + } + return { + 'year': year, + 'month': month, + 'day': day + } + + def lunarToSolar(self, _year, _month, _day): + """ + 將農曆轉換為公曆 + @param {Number} year,month,day 農曆年,月(1-13,有閏月),日 + year: Gregorian year + """ + inputDate = self.__formatDate(_year, _month, _day) + try: + inputDate['error'] + except KeyError: + pass + else: + return inputDate + year = inputDate['year'] + month = inputDate['month'] + day = inputDate['day'] + + between = self.__getDaysBetweenZheng(year, month, day) # 離正月初一的天數 + yearData = self.__lunarInfo[year - self.__minYear] + zenMonth = yearData[1] + zenDay = yearData[2] + + offDate = ( + datetime.date(year, zenMonth, zenDay) + + datetime.timedelta(days=between) + ) + return { + 'year': offDate.year, + 'month': offDate.month, + 'day': offDate.day + } + + def solarToLunar(self, _year, _month, _day): + """ + Converts Gregorian date to lunar calendar. + _year: integer between 1891 and 2100 inclusive. + _month, _day: integer, 1-indexed. + """ + inputDate = self.__formatDate(_year, _month, _day, self.__minYear) + try: + inputDate['error'] + except KeyError: + pass + else: + return inputDate + + year = inputDate['year'] + month = inputDate['month'] + day = inputDate['day'] + + try: + termList = self.__cache[year] # 二十四節氣 + except KeyError: + self.__cache[year] = self.__getYearTerm(year) + termList = self.__cache[year] + + term2 = [k for (k, v) in termList.items() if v == '立春'][0] + term2 = int(term2[-1]) + + firstTerm = self.__getTerm(year, month * 2) + GanZhiYear = ( + year + 1 if month > 1 or month == 1 and day >= term2 + else year + ) + GanZhiMonth = month + 1 if day >= firstTerm else month + + lunarDate = self.__getLunarByBetween(year, month, day) + lunarLeapMonth = self.__getLunarLeapYear(lunarDate[0]) + + if lunarLeapMonth > 0 and lunarLeapMonth == lunarDate[1]: + lunarMonthName = '閏' + self.__monthCn[lunarDate[1] - 1] + '月' + elif lunarLeapMonth > 0 and lunarDate[1] > lunarLeapMonth: + lunarMonthName = self.__monthCn[lunarDate[1] - 1] + '月' + else: + lunarMonthName = self.__monthCn[lunarDate[1]] + '月' + + try: + term = termList[self.__formatDayD4(month, day)] + except KeyError: + term = None + return { + 'zodiac': self.__getYearZodiac(GanZhiYear), + 'GanZhiYear': self.__getLunarYearName(GanZhiYear), + 'GanZhiMonth': self.__getLunarMonthName(year, GanZhiMonth), + 'GanZhiDay': self.__getLunarDayName(year, month, day), + 'term': term, + 'lunarYear': lunarDate[0], + 'lunarMonth': lunarDate[1] + 1, + 'lunarDay': lunarDate[2], + 'lunarMonthName': lunarMonthName, + 'lunarDayName': self.__dateCn[lunarDate[2] - 1], + 'lunarLeapMonth': lunarLeapMonth + } diff --git a/lunarInfo.csv b/lunarInfo.csv new file mode 100644 index 0000000..6b82f59 --- /dev/null +++ b/lunarInfo.csv @@ -0,0 +1,211 @@ +2,1,21,22184 +0,2,9,21936 +6,1,30,9656 +0,2,17,9584 +0,2,6,21168 +5,1,26,43344 +0,2,13,59728 +0,2,2,27296 +3,1,22,44368 +0,2,10,43856 +8,1,30,19304 +0,2,19,19168 +0,2,8,42352 +5,1,29,21096 +0,2,16,53856 +0,2,4,55632 +4,1,25,27304 +0,2,13,22176 +0,2,2,39632 +2,1,22,19176 +0,2,10,19168 +6,1,30,42200 +0,2,18,42192 +0,2,6,53840 +5,1,26,54568 +0,2,14,46400 +0,2,3,54944 +2,1,23,38608 +0,2,11,38320 +7,2,1,18872 +0,2,20,18800 +0,2,8,42160 +5,1,28,45656 +0,2,16,27216 +0,2,5,27968 +4,1,24,44456 +0,2,13,11104 +0,2,2,38256 +2,1,23,18808 +0,2,10,18800 +6,1,30,25776 +0,2,17,54432 +0,2,6,59984 +5,1,26,27976 +0,2,14,23248 +0,2,4,11104 +3,1,24,37744 +0,2,11,37600 +7,1,31,51560 +0,2,19,51536 +0,2,8,54432 +6,1,27,55888 +0,2,15,46416 +0,2,5,22176 +4,1,25,43736 +0,2,13,9680 +0,2,2,37584 +2,1,22,51544 +0,2,10,43344 +7,1,29,46248 +0,2,17,27808 +0,2,6,46416 +5,1,27,21928 +0,2,14,19872 +0,2,3,42416 +3,1,24,21176 +0,2,12,21168 +8,1,31,43344 +0,2,18,59728 +0,2,8,27296 +6,1,28,44368 +0,2,15,43856 +0,2,5,19296 +4,1,25,42352 +0,2,13,42352 +0,2,2,21088 +3,1,21,59696 +0,2,9,55632 +7,1,30,23208 +0,2,17,22176 +0,2,6,38608 +5,1,27,19176 +0,2,15,19152 +0,2,3,42192 +4,1,23,53864 +0,2,11,53840 +8,1,31,54568 +0,2,18,46400 +0,2,7,46752 +6,1,28,38608 +0,2,16,38320 +0,2,5,18864 +4,1,25,42168 +0,2,13,42160 +10,2,2,45656 +0,2,20,27216 +0,2,9,27968 +6,1,29,44448 +0,2,17,43872 +0,2,6,38256 +5,1,27,18808 +0,2,15,18800 +0,2,4,25776 +3,1,23,27216 +0,2,10,59984 +8,1,31,27432 +0,2,19,23232 +0,2,7,43872 +5,1,28,37736 +0,2,16,37600 +0,2,5,51552 +4,1,24,54440 +0,2,12,54432 +0,2,1,55888 +2,1,22,23208 +0,2,9,22176 +7,1,29,43736 +0,2,18,9680 +0,2,7,37584 +5,1,26,51544 +0,2,14,43344 +0,2,3,46240 +4,1,23,46416 +0,2,10,44368 +9,1,31,21928 +0,2,19,19360 +0,2,8,42416 +6,1,28,21176 +0,2,16,21168 +0,2,5,43312 +4,1,25,29864 +0,2,12,27296 +0,2,1,44368 +2,1,22,19880 +0,2,10,19296 +6,1,29,42352 +0,2,17,42208 +0,2,6,53856 +5,1,26,59696 +0,2,13,54576 +0,2,3,23200 +3,1,23,27472 +0,2,11,38608 +11,1,31,19176 +0,2,19,19152 +0,2,8,42192 +6,1,28,53848 +0,2,15,53840 +0,2,4,54560 +5,1,24,55968 +0,2,12,46496 +0,2,1,22224 +2,1,22,19160 +0,2,10,18864 +7,1,30,42168 +0,2,17,42160 +0,2,6,43600 +5,1,26,46376 +0,2,14,27936 +0,2,2,44448 +3,1,23,21936 +0,2,11,37744 +8,2,1,18808 +0,2,19,18800 +0,2,8,25776 +6,1,28,27216 +0,2,15,59984 +0,2,4,27424 +4,1,24,43872 +0,2,12,43744 +0,2,2,37600 +3,1,21,51568 +0,2,9,51552 +7,1,29,54440 +0,2,17,54432 +0,2,5,55888 +5,1,26,23208 +0,2,14,22176 +0,2,3,42704 +4,1,23,21224 +0,2,11,21200 +8,1,31,43352 +0,2,19,43344 +0,2,7,46240 +6,1,27,46416 +0,2,15,44368 +0,2,5,21920 +4,1,24,42448 +0,2,12,42416 +0,2,2,21168 +3,1,22,43320 +0,2,9,26928 +7,1,29,29336 +0,2,17,27296 +0,2,6,44368 +5,1,26,19880 +0,2,14,19296 +0,2,3,42352 +4,1,24,21104 +0,2,10,53856 +8,1,30,59696 +0,2,18,54560 +0,2,7,55968 +6,1,27,27472 +0,2,15,22224 +0,2,5,19168 +4,1,25,42216 +0,2,12,42192 +0,2,1,53584 +2,1,21,55592 +0,2,9,54560