万年历是如何计算的,python实现

lzlz000的个人主页

网上能搜到不少万年历的代码,但是这应该是最详细的万年历介绍,有对天文历法、农历转换原理的的详细介绍

一份万年历应该包含的内容有公历公元纪年、日期、星期;农历干支纪年、日期、二十四节气。本文主要写的是以一个给定的公历日期来查询星期,农历日期,至于节日,完全可以根据给定的公历农历日期、星期查表获得,比较简单不再描述, 例如这样

# 公历节日字典
{
    '1001': '国庆节',
    '0501': '劳动节',
}
# 农历节日字典 需要注意的是除夕的判断,由于农历12月有可能有29或者30天,需要判断处理
{
    '0101': '春节',
    '0815': '重阳节',
}
# 公历月份星期节日 520即 5月第2个星期日
{
    '520' : '母亲节',
    '1021' : '10月第2个星期1 不好意思今天没有节日'
}

至于宜忌,(・∀・(・∀・(・∀・*),我觉得我写的是一篇科普向的文章,封建迷信就算了吧,据我所知,程序对这种内容八成是随机生成的,或者爬黄历网站的数据,然而还有人相信,权当自我安慰吧。

公历

公历是一种太阳历,即以地球绕日公转为基础制定的历法。以地球自转一周为一天,一年有365.2422天(回归年,历法基本以回归年为基准,回归年以太阳直射地球的纬度的回归周期计算,恒星年(以地球在公转轨道的位置回归周期计算)和回归年有小的差别,因为地球自转轴是在摆动的)。

闰年

为了弥补自传和公转的插值,保证每一年的同一天太阳直射地球的纬度大致相同(否则会出不同年的同一天所在的季节一直在变化),就产生了闰年,其规则为每400年有97个闰年,四年一闰,百年不闰,四百年再闰。用代码来表达:

# 判断公元纪年是否为闰年
def is_leap_year(year:int):
    if year % 4 == 0:
        if year % 100 == 0 and year % 400 == 0:
                return True
        elif year % 100 != 0:
            return True
    return False

星期

蔡勒(Zeller)公式 是一个计算星期的公式,是一个简化星期的计算的技巧,给出一个日期,就能用这个公式推算出是星期几。

def zeller(year: int, month: int, day: int):
    """
    蔡勒公式,计算指定年月日是星期几的算法

    :param year:  年
    :param month: 月
    :param day:   日
    :return: 0-星期日,1-星期一,2-星期二,3-星期三,4-星期四,5-星期五,6-星期六
    """
    # 在蔡勒公式中,某年的1、2月要看作上一年的13、14月来计算,比如2019年1月1日要看作2018年的13月1日来计算
    if month < 3:
        m = month + 12
        year = year - 1
    else:
        m = month
    # 公元前1年和公元元年(1年)相邻,没有0年
    if year == 0:
        year = -1
    y = year % 100  # 取年的后两位
    # 世纪 取已经经过的世纪,即20xx取20而不是21世纪
    c = (year - y) / 100
    # 注意,如果年份是公元前的年份且非整百数的话,c应该等于所在世纪的编号,如公元前253年,是公元前3世纪
    if c < 0 and y != 0:
        c -= 1
    return (y + int(y / 4) + int(c / 4) - 2 * c + int(26 * (m + 1) / 10) + day - 1) % 7

农历

农历是混合历,日期记录以月亮历为基础,月球绕地公转周期约29.53059日,因此农历有大小月之分,大月30天小月29天。

农历日期

农历实际上没有非常强的规律性,它的定义是依赖天文观测值推算的,因此通常存储当前前后一百多年的观测/推算值作为农历数据,因此,所谓万年历是假的,大多数只能查询1900-2049这段时间的农历日期。

个人认为,这其实就是中国传统天文历法的缺陷性,观测只是手段,甚至无法用规律性的公式来表达历法,只能查表,没有追求对天体规律的研究,而把天象当作凶吉之兆,真是非常可惜的。要知道,西方文明对于天体运动的精确观测和规律的研究对牛顿提出万有引力定律和力学定律产生了直接的影响(再说牛顿被苹果砸想出万有引力定律的拖出去枪毙五分钟),从此我们知道了天上的星星和地上的人遵循着同样的运行规则。我们的文明智慧是不差的,点错了科技树实在是可惜啊。

当前日期和初始日期的间隔来得到具体是农历年月日以及是否为闰月

from datetime import datetime
# from datetime import timedelta

# 1900-2049年的农历数据表,5个16进制数,共20bit
# - 前4位,在这一年是润年时才有意义,它代表这年润月的大小月,为1则润大月,为0则润小月。
# - 中间12位,即4bit,每位代表一个月,为1则为大月,为0则为小月。
# - 最后4位,即8,代表这一年的润月月份,为0则不润。首4位要与末4位搭配使用
lunar_info = [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]
gan = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]
zhi = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
mon_str = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '腊']
day_str1 = ['初', '十', '廿']
day_str2 = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']

# 初始条件:1900年1月31日为农历正月初一
first_lunar_day = datetime(1900, 1, 31)


# 获取一个农历年有多少天
def sum_days(offset):
    info = lunar_info[offset]
    # 获取月份信息
    month_info = (info & 0x0ffff) >> 4
    big_month_count = 0
    # 循环计数有多少位二进制1,原理如下:
    # (2)10101 - 1 = (2)10100,  
    # (2)10101 & (2)10100 = (2)10100
    # (2)10100 - 1 = (2)10011
    # (2)10100 & (2)10011 = (2)10000
    # 所有二进制值-1得到的结果有这样的特点:最后一位1变成0,最后一位1之后的0都变成1,其他不变
    # 这样取 & 运算会得到把最后一位1置为0的效果,循环次数便为1的数量
    while month_info != 0:
        month_info = month_info & (month_info - 1)
        big_month_count += 1
    days = 29 * 12 + big_month_count
    # 如果有闰月
    if (info & 0x0000f) > 0:
        # 闰月是否为大月
        if (info & 0x10000) > 0:
            days += 30
        else:
            days += 29
    return days


def month_str(month):
    return mon_str[month - 1]


def day_str(day):
    if day == 10:
        return "初十"
    if day == 20:
        return "二十"
    if day == 30:
        return "三十"
    return day_str1[int(day / 10)] + day_str2[day % 10 - 1]


def ganzhi_year(year):
    offset = year - 1864  # 1864是一个甲子年
    return gan[offset % 10] + zhi[offset % 12]


# 获取在指定年之前有多少天
def lunar_str(year, month, day):
    interval_days = (datetime(year, month, day) - first_lunar_day).days + 1
    # print('interval_days', interval_days)
    sum_day = 0
    year_offset = 0
    while True:
        temp = sum_day + sum_days(year_offset)
        if temp >= interval_days:
            break
        year_offset += 1
        sum_day = temp
    info = lunar_info[year_offset]  # 所在年的信息
    rest_days = interval_days - sum_day  # 还剩下多少天没有计算
    month = 0
    month_info = (info & 0x0ffff) >> 4
    leap_month = info & 0x0000ff
    has_leap_month = leap_month > 0
    leap_month -= 1
    days = 0
    for i in range(0, 12):
        temp = days
        # 判断是否为大月
        temp += 30 if (0x800 >> i) & month_info > 0 else 29
        if temp >= rest_days:
            month = i + 1
            break
        days = temp
        if has_leap_month and i == leap_month:
            temp += 30 if (info & 0x10000) > 0 else 29  # 闰月是大月还是小月
        if temp >= rest_days:
            month = "闰" + str(i + 1)
            break
        days = temp
    day = rest_days - days
    # 注意农历和下一年公历重叠的情况
    return '{0}年 {1}月{2}'.format(ganzhi_year(1900 + year_offset),  month_str(month),  day_str(day))

print(lunar_str(2000, 2, 5))
print(lunar_str(2000, 3, 6))
print(lunar_str(2000, 4, 5))
print(lunar_str(2000, 5, 4))
print(lunar_str(2000, 6, 2))
print(lunar_str(2000, 7, 2))
print(lunar_str(2000, 10, 26))
print(lunar_str(2000, 10, 27))
print(lunar_str(2000, 11, 25))
print(lunar_str(2000, 11, 26))
print(lunar_str(2000, 12, 25))
print(lunar_str(2000, 12, 26))
print(lunar_str(2001, 1, 23))
print(lunar_str(2001, 1, 24))
print(lunar_str(2019, 7, 2))

运行结果

庚辰年 正月初一
庚辰年 二月初一
庚辰年 三月初一
庚辰年 四月初一
庚辰年 五月初一
庚辰年 六月初一
庚辰年 九月廿九
庚辰年 十月初一
庚辰年 十月三十
庚辰年 十一月初一
庚辰年 十一月三十
庚辰年 腊月初一
庚辰年 腊月廿九
辛巳年 正月初一
己亥年 五月三十

二十四节气

24节气是太阳历,其定义以地球在绕日轨道上的位置为基准,冬至即太阳照射到地球南回归线上的时刻,夏至为北回归线,春分秋分就是太阳直射赤道的时刻,所以二十四节气的是有精确的时刻,这个时刻所在的那一天便是节气的日期。由于地球日和公转回归年的差异,这个日期会在一两天内摇摆。

节气的定法有两种。古代历法采用的称为”恒气”,即按时间把一年等分为24份,每一节气平均得15天有余,所以又称”平气”。现代农历采用的称为”定气”,即按地球在轨道上的位置为标准,一周360°,两节气之间相隔15°。由于地球的公转轨道其实是椭圆,冬至时地球位于近日点附近,运动速度较快,因而太阳在黄道上移动15°的时间不到15天。夏至前后的情况正好相反,太阳在黄道上移动较慢,一个节气达16天之多。采用定气时可以保证春、秋两分必然在昼夜平分的那两天,可以更好的体现节气对季节的区分作用。

定义24个节气与第一个节气所相差的时间长度(分钟),然后以一个确定的时刻作为标准,上面已经说过一个公转回归年为365.2422天即 525948.768分钟,由于节气均不在元旦日期附近,即使由于闰年相差一两天也不会跨年,直接以年份和基准节气时间(本例中的1900年1月6日 2:05:00AM)的间隔*525948.768 数加上当前节气时间间隔数便是本年24节气的时间。

solarTerm = ["小寒", "大寒", "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", "立夏", "小满", "芒种", "夏至", "小暑", "大暑", "立秋", "处暑", "白露", "秋分", "寒露", "霜降", "立冬", "小雪", "大雪", "冬至"]
sTermInfo = [0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, 173149, 195551, 218072, 240693, 263343, 285989, 308563, 331033, 353350, 375494, 397447, 419210, 440795, 462224, 483532, 504758]
# 1900年小寒
solarTermBase = datetime(1900, 1, 6, 2, 3, 57)

def solar_term_of_year(year):
    year_min = 525948.768
    base_year = 1900
    # 指定年的小寒节气
    first_term_of_year = solarTermBase + timedelta(minutes=(year - base_year) * year_min)
    for index,minutes in enumerate(sTermInfo):
        term = first_term_of_year + timedelta(minutes=minutes)
        # print(term.strftime( '%Y-%m-%d %H:%M:%S')+solarTerm[index])
        print(term.strftime( '%Y-%m-%d')+solarTerm[index])
      
solar_term_of_year(2018)

注意,sTermInfo数据的不准确,计算出来的值和现在查询到的2019年精确值是有误差,如果刚好在0点附近就会产生日期不对的情况,我还没找到一个准确的数据可以使用,但是作为原理的介绍没有问题

2018-01-05小寒
2018-01-20大寒
2018-02-04立春
2018-02-18雨水
2018-03-05惊蛰
2018-03-20春分
2018-04-05清明
2018-04-20谷雨
2018-05-05立夏
2018-05-21小满
2018-06-06芒种
2018-06-21夏至
2018-07-07小暑
2018-07-23大暑
2018-08-07立秋
2018-08-23处暑
2018-09-08白露
2018-09-23秋分
2018-10-08寒露
2018-10-23霜降
2018-11-07立冬
2018-11-22小雪
2018-12-07大雪
2018-12-22冬至