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中如何二进制的基本用法,以及应用案例我会另开文章来写。至于涉及到的其他,我觉得需要整理的基础知识点也会陆续补充上来,作为分享以及自己的学习笔记。

计划中逐步完成的相关文章清单:

  • Python中二进制的使用 (撰写中)
  • Python自定义类中的函数重载,如何自定义打印字符串,自定义比较,以及加减运算符(未开始)
  • 如何将自己的代码让 pip 能够 install (未开始)
  • 其他想到的
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容

  • 如果不注意,大概很多人认为“闰月”与“闰年”是一个意思,其实不是,虽说只是一字之差,所包含的意思却相差很远。 “闰...
    雨落未惊风阅读 7,159评论 1 1
  • 在古代,算命一直都是一种精英文化行为。随便举出古代研究易学命理的人,都是些牛人,不是大文豪,就是大哲学家。比如...
    DUU_e50f阅读 11,589评论 6 26
  • 1/30/18 Elaine 1. 情怀 格局与气度。 “被满足”和“能承担”。 ...
    Elaine旅晴阅读 520评论 0 1
  • 琳琳在家炒大白菜炖脂渣,又做米饭,炒豆腐干,下班到家一头扎进在厨房忙活今晚的晚饭。妈妈从这么远的地方坐车给我们...
    文心怡筱雅阅读 311评论 0 0
  • 在路上 文/高小蔚 《上》 在路上,朋友送我的CD的名字。汇集了很多人的歌,大都没有听过,却是因了这三个字,对它莫...
    高小蔚阅读 271评论 6 7