Python 农历公历算法转换

Python 农历公历算法转换

背景

日常用python处理各种数据分析工作,最近需要对历年春节期间的数据做一些对比工作,本来只是用了一个简单的日期数组来进行,但后来发现一些数据在农历日期进行对比的时候,会有一些有趣的规律,进而产生了公历农历进行互转的需求。

本来以为网上有现成的库或者是文章,结果发现要不是请求网络Api,要么就是数据有错误,语言不是Python的等等。由于基于是10万量级的数据,网络请求转换明显是不可能的,所以自己写了一个本地转换的库,研究过程中又发现了一些比较有趣的在平时开发中用的不多的算法和Python基础,就都添加了上去,并成为我第一个发布的pypi包。这篇文章主要介绍基础算法和使用方法,后续会把那些Python基础知识也补充进去。

项目使用说明

先上项目吧,想直接使用的同学,拿来就能用了 ZhDate GitHub主页,对开发过程有兴趣的请继续往下看。

安装方法

通过 pip 直接安装

pip install zhdate

或从git拉取

git clone https://github.com/CutePandaSh/zhdate.git
cd zhdate
python setup.py install

更新

pip install zhdate --upgrade

使用方法

见如下代码案例:

from zhdate import ZhDate

date1 = ZhDate(2010, 1, 1) # 新建农历 2010年正月初一 的日期对象
print(date1)  # 直接返回农历日期字符串
dt_date1 = date1.to_datetime() # 农历转换成阳历日期 datetime 类型

dt_date2 = datetime(2010, 2, 6)
date2 = ZhDate.from_datetime(dt_date2) # 从阳历日期转换成农历日期对象

date3 = ZhDate(2020, 4, 30, leap_month=True) # 新建农历 2020年闰4月30日
print(date3.to_datetime())

# 支持比较
if ZhDate(2019, 1, 1) == ZhDate.from_datetime(datetime(2019, 2, 5)):
    pass

# 减法支持
new_zhdate = ZhDate(2019, 1, 1) - 30  #减整数,得到差额天数的新农历对象
new_zhdate2 = ZhDate(2019, 1, 1) - ZhDate(2018, 1, 1) #两个zhdate对象相减得到两个农历日期的差额
new_zhdate3 = ZhDate(2019, 1, 1) - datetime(2019, 1, 1) # 减去阳历日期,得到农历日期和阳历日期之间的天数差额

# 加法支持
new_zhdate4 = ZhDate(2019, 1, 1) + 30 # 加整数返回相隔天数以后的新农历对象

# 中文输出
new_zhdate5 = ZhDate(2019, 1, 1)
print(new_zhdate5.chinese())

# 当天的农历日期
ZhDate.today()

核心算法

重要的事情说三遍

农历不是算出来的,是天文台观测出来的
农历不是算出来的,是天文台观测出来的
农历不是算出来的,是天文台观测出来的

所以也想做农历功能的同学就不要费心去学什么农历算法了,浪费了我三天时间也没看懂到底是怎么计算的。
目前通用的也是比较准确的,可下载的农历阳历对照数据是 香港天文台农历对照表(文字版), 可下载txt格式的农历对照数据。写了一个简单的爬虫,将所有txt文件下载下来。注意获得到的txt是Big5的,并且需要跳过头部的三行,头部三行是每个文件的年份基础信息。可以用以下代码来读取,这里还用到了如何跳过文件头部n行,以及打开非utf8编码格式文件的小技巧。

with open('./{年份}.txt', encoding='big5') as file:
     for n_line, line in enumerate(file.readline()):
        if n_line < 3:
            continue
       else:
            dosomething()

下载到的数据是从 公历 1901年1月1日,农历 1900年11月11日起,至 2100年12月31日,农历 2100年12月1日之间的200年的每天对照数据。经过编码转换后,重新存一个json或者pickle文件就可以直接拿来用了,速度也不慢。但是这个包含了所有日期数据的文件,json格式的话,有6M多,字典pickle格式也有2M多,显然不利于传播和重复使用。参考了网上一篇Java的农历转换源码,虽然使用的基础数据存在错误,但是算法非常精辟,所以就 拿来主义 了。

香港天文台原始数据处理

从原始数据处理转换成可用于统计和进一步处理的完整代码如下:

from datetime import datetime

CHINESENUMBERS = {
    '一': 1,
    '二': 2,
    '三': 3,
    '四': 4,
    '五': 5,
    '六': 6,
    '七': 7,
    '八': 8,
    '九': 9,
    '十': 10,
    '正': 1
}

def read_single_file(file_name, coding="big5"):
    result = list()
    with open(file_name, encoding=coding) as file:
        for idx, l in enumerate(file.readlines()):
            if idx < 3:
                continue
            else:
                result.append(list(filter(lambda x: x != "" and x != "\n", l.split(" "))))
    return result

def day_data_process(day_data, c_year, c_month, c_leap=False):
    day_info = dict()
    date = datetime.strptime(day_data[0], '%Y年%m月%d日')
    day_info['year'] = date.year
    day_info['month'] = date.month
    day_info['day'] = date.day

    chinese_day = day_data[1]
    if chinese_day == '正月':
        day_info['lunar_year'] = c_year + 1
    else:
        day_info['lunar_year'] = c_year
    
    if chinese_day[-1] == '月':
        if chinese_day[0] == '閏':
            day_info['lunar_leap'] = True
            if len(chinese_day) == 4:
                day_info['lunar_month'] = 10 + CHINESENUMBERS[chinese_day[2]]
            else:
                day_info['lunar_month'] = CHINESENUMBERS[chinese_day[1]]
        else:
            day_info['lunar_leap'] = False
            if len(chinese_day) == 3:
                day_info['lunar_month'] = 10 + CHINESENUMBERS[chinese_day[1]]
            else:
                day_info['lunar_month'] = CHINESENUMBERS[chinese_day[0]]
        day_info['lunar_day'] = 1
    else:
        day_info['lunar_month'] = c_month
        day_info['lunar_leap'] = c_leap

        if chinese_day[0] == '初':
            day_info['lunar_day'] = CHINESENUMBERS[chinese_day[1]]
        elif chinese_day[0] == '十':
            day_info['lunar_day'] = 10 + CHINESENUMBERS[chinese_day[1]]
        elif chinese_day[0] == '廿':
            day_info['lunar_day'] = 20 + CHINESENUMBERS[chinese_day[1]]
        elif chinese_day == '二十':
            day_info['lunar_day'] = 20
        elif chinese_day == '三十':
            day_info['lunar_day'] = 30
    
    return day_info

def lunar_data():
    data_list = list()
    for i in range(1901, 2101):
        data_list = data_list + read_single_file(f"./rawdata/{i}.txt")
    lunar_calendar_data = list()
    for day in data_list:
        try:
            datetime.strptime(day[0], '%Y年%m月%d日')
        except:
            continue
        if len(lunar_calendar_data) != 0:
            lunar_calendar_data.append(
                day_data_process(day, lunar_calendar_data[-1]['lunar_year'], lunar_calendar_data[-1]['lunar_month'], lunar_calendar_data[-1]['lunar_leap'])
            )
        else:
            lunar_calendar_data.append(day_data_process(day, 1900, 11))
    
    return lunar_calendar_data

上述代码可返回一个每天日期信息字典的List,可再使用pandas对这些数据进行编码。编码过程略。

年度数据编码

每一整年的数据可用 20位的二进制数表示

0001 1000 1000 1000 1000
  • 第一部分,最左边的前4位,只有0或1,0表示当年闰月为小月(即29天),1表示当年闰月为大月(即30天),这个需要和最右侧的最后4位结合使用。
  • 第二部分,中间的12位,表示当年农历年每月的大小月,0表示小月,1表示大月,忽略闰月,从左起第一位表示1月。
  • 第三部分,最右侧的最后4位,转换成10进制表示当年的闰月月份,如果闰月不存在那就为 0。


举例说明

2019年的年度编码 43312

转换成二进制为

0000 1010 1001 0011 0000

位数不足左侧补0, 解析如下:

  • 先考虑中间12位表示月份,形成月份天数数组 [30, 29, 30, 29, 30, 29, 29, 30, 29, 29, 30, 30],此为农历1-12月的月份天数。
  • 再看最后4位,等于0,表示当年无闰月
  • 解析完成


2020年的年度编码 31060

转换成二进制为

0000 0111 1001 0101 0100

位数不足左侧补0, 解析如下:

  • 先考虑中间12位表示月份,形成月份天数数组 [29, 30, 30, 30, 30, 29, 29, 30, 29, 30, 29, 30],此为农历1-12月的月份天数。
  • 再看最后4位,转换10进制,等于4,表示当年存在 闰4月
  • 查看最左侧,前4位,等于0,表示当年闰4月为小月,只有29天
  • 在初始月份数组的 4月后插入 29,形成新的月份天数List [29, 30, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30],这里包含13个月,含闰月的天数。
  • 解析完成


坑爹的网上农历说明

有些网站上提到每年的闰月应该和实际月天数相同,比如上述的例子,按照说明那么 2020年的农历4月和农历闰4月的天数是相同的,实际上是不同的,所以按照天文台的数据进行处理吧。

年度编码解析代码

def decode(year_code):
    """解析年度农历代码函数
    
    Arguments:
        year_code {int} -- 从年度代码数组中获取的代码整数
    
    Returns:
        [int] -- 当前年度代码解析以后形成的每月天数数组,已将闰月嵌入对应位置,即有闰月的年份返回长度为13,否则为12
    """
    month_days = list()
    for i in range(5, 17):
        if (year_code >> (i - 1)) & 1:
            month_days.insert(0, 30)
        else:
            month_days.insert(0, 29)
    if year_code & 0xf:
        if year_code >> 16:
            month_days.insert((year_code & 0xf), 30)
        else:
            month_days.insert((year_code & 0xf), 29)
    return month_days

香港天文台能下载到的只有1901年-2100年的数据,作为一个强迫症患者,看到这个1901总是不爽,在百度上查了一下,正好它支持1900年2050年的数据,所以手动添加了1900的部分,形成了这个项目中的1900 - 2100年的完整农历数据。

为了加快运算除了年度代码,还存储了每年的农历正月初一的公历日期,这样就用了20K就保存了200年的农历数据。

天干地支算法

天干地支是中国特有的一种历法,看起来很复杂,实际上用简单的代码就用打印出来

tian = '甲乙丙丁戊己庚辛壬癸'
di = '子丑寅卯辰巳午未申酉戌亥'
for i in range(0, 60):
    print(f"{i:} {tian[i % 10]}{di[i % 12]}")

----------------
0 甲子
1 乙丑
2 丙寅
3 丁卯
4 戊辰
5 己巳
6 庚午
...(略)
51 乙卯
52 丙辰
53 丁巳
54 戊午
55 己未
56 庚申
57 辛酉
58 壬戌
59 癸亥

对的,就是这么简单,天干是10进制,地支是12进制,所以每一个序数对10取余数,得到天干,每个序数对12取余数得到地支,相互组合就是该序数对应的天干地支数。所以不用查表,用的时候直接打印一份就行了。

年度的天干地支最容易算,需要注意的是必须使用农历年份,不能用公历年份。查下百度得知 1900年为 庚子年,序号 36,所以用以下代码可获得当前农历年的天干地支

def year_tiandi(year):
    td_num = year - 1900 + 36
    tian = '甲乙丙丁戊己庚辛壬癸'
    di = '子丑寅卯辰巳午未申酉戌亥'
    return f"{tian[td_num % 10]}{di[td_num % 12]}年"

总结

以上就是整个项目中最核心的部分,本质上来说,这个项目并不涉及复杂算法,最核心的是使用二进制来压缩存储年度数据,相关的在Python中如何二进制的基本用法,以及应用案例我会另开文章来写。至于涉及到的其他,我觉得需要整理的基础知识点也会陆续补充上来,作为分享以及自己的学习笔记。

发布于 2019-02-21

文章被以下专栏收录