历法小记

为什么春节的日期每年都在变化?

这是因为这些中国传统节日并不是基于公历确定的,而是根据中国传统历法中的农历来推算。一个简单的判断方法是,只需要确定立春当日对应的农历日期即可。若立春位于当月农历的 1 至 15 日之间,则这个农历月就对应春节至元宵所在的正月;若立春位于当月农历十五之后,则下一个农历月的初一才是春节。以 2023 年为例,立春时间为公历 2 月 4 日,这一天对应农历十四,因此这个农历月就被视作正月,本月农历初一也就是正月初一。

至于节气的具体日期如何测算,这本身是一项相对复杂的天文计算,通常由中国科学院紫金山天文台等机构进行测算并向社会公布。

一种简单的计算方法如下。

近似公式:

\[D = \lfloor 0.2422Y + C\rfloor-\lfloor\frac{Y}{4}\rfloor\]

其中:

  • D:节气所在公历日
  • Y:年份后两位
  • C:该节气对应常数

2000–2099,可取:

\[L = \lfloor\frac{Y}{4}\rfloor\]

1901–1999,可取:

\[L = \lfloor\frac{Y - 1}{4}\rfloor\]

常数对照表如下:

节气 月份 20世纪C值 21世纪C值
小寒 1 6.11 5.4055
大寒 1 20.84 20.12
立春 2 4.6295 3.87
雨水 2 19.4599 18.73
惊蛰 3 6.3826 5.63
春分 3 21.4155 20.646
清明 4 5.59 4.81
谷雨 4 20.888 20.1
立夏 5 6.318 5.52
小满 5 21.86 21.04
芒种 6 6.5 5.678
夏至 6 22.20 21.37
小暑 7 7.928 7.108
大暑 7 23.65 22.83
立秋 8 8.35 7.5
处暑 8 23.95 23.13
白露 9 8.44 7.646
秋分 9 23.822 23.042
寒露 10 9.098 8.318
霜降 10 24.218 23.438
立冬 11 8.218 7.438
小雪 11 23.08 22.36
大雪 12 7.9 7.18
冬至 12 22.60 21.94

Python Code

from math import floor

SOLAR_TERMS = [
    ("小寒", 1, 6.11, 5.4055),
    ("大寒", 1, 20.84, 20.12),
    ("立春", 2, 4.6295, 3.87),
    ("雨水", 2, 19.4599, 18.73),
    ("惊蛰", 3, 6.3826, 5.63),
    ("春分", 3, 21.4155, 20.646),
    ("清明", 4, 5.59, 4.81),
    ("谷雨", 4, 20.888, 20.1),
    ("立夏", 5, 6.318, 5.52),
    ("小满", 5, 21.86, 21.04),
    ("芒种", 6, 6.5, 5.678),
    ("夏至", 6, 22.20, 21.37),
    ("小暑", 7, 7.928, 7.108),
    ("大暑", 7, 23.65, 22.83),
    ("立秋", 8, 8.35, 7.5),
    ("处暑", 8, 23.95, 23.13),
    ("白露", 9, 8.44, 7.646),
    ("秋分", 9, 23.822, 23.042),
    ("寒露", 10, 9.098, 8.318),
    ("霜降", 10, 24.218, 23.438),
    ("立冬", 11, 8.218, 7.438),
    ("小雪", 11, 23.08, 22.36),
    ("大雪", 12, 7.9, 7.18),
    ("冬至", 12, 22.60, 21.94),
]


TERM_EXCEPTIONS = {
    # "小寒": {1982: +1, 2019: -1},
    # "立春": {2021: -1},
    # "雨水": {2026: -1},
}

def leap_correction(year: int) -> int:
    y = year % 100
    if 1901 <= year <= 1999:
        return floor((y - 1) / 4)
    elif 2000 <= year <= 2099:
        return floor(y / 4)
    else:
        raise ValueError

def get_c_value(year: int, c20: float, c21: float) -> float:
    if 1901 <= year <= 1999:
        return c20
    elif 2000 <= year <= 2099:
        return c21
    else:
        raise ValueError

def solar_term_day(year: int, name: str, month: int, c20: float, c21: float) -> int:
    y = year % 100
    c = get_c_value(year, c20, c21)
    l = leap_correction(year)

    day = floor(y * 0.2422 + c) - l

    day += TERM_EXCEPTIONS.get(name, {}).get(year, 0)

    return day

def list_solar_terms(year: int):
    result = []
    for name, month, c20, c21 in SOLAR_TERMS:
        day = solar_term_day(year, name, month, c20, c21)
        result.append((name, month, day))
    return result

if __name__ == "__main__":
    year = 2026
    print(f"{year} 年二十四节气")
    for name, month, day in list_solar_terms(year):
        print(f"{name}: {year}-{month:02d}-{day:02d}")

民间常把农历称作阴历,因为它确实与月相盈亏密切相关;但严格来说,农历并不是纯粹的阴历,而是一种阴阳合历。与基于太阳周年运动的阳历不同,它并不单纯根据地球绕太阳公转的轨迹来计算。更准确地说,农历中的与太阳相关,而月份和日期则由朔望月决定。朔望月指的是月亮连续两次合朔之间的时间。在古代,人们将定义为全黑的新月,在天文学上,它对应月亮黄经与太阳黄经相同的时刻;此时月亮位于地球和太阳之间,在地球上只能看见月亮的暗面。而则对应满月,是从地球观测时月亮被太阳完全照亮的状态,此时太阳与月亮的黄经相差 180 度。一个朔望月大约是 29.53 天,因此一年十二个朔望月约为 $29.53 \times 12 = 354.36$ 天,比公历年要少若干天。为了弥补这一差距,农历采用置闰的方式进行调整,其基本规则就是「十九年七闰」,即在 19 年中加入 7 个闰月。这样一来,19 年中农历的实际天数约为 $29.53 \times (12 \times 19 + 7) = 6939.55$,与公历的 $365.24 \times 19 = 6939.56$ 已经非常接近了。

再说说回归年和恒星年。回归年是指从地球上观察,太阳再次回到黄道上同一位置所经历的时间,具体约为 365 天 5 小时 48 分 46 秒。恒星年则是地球真正的公转周期,也就是太阳在天球上返回相对于恒星同一位置所经历的时间,约为 365 天 6 小时 9 分 10 秒。农历将一个回归年大致等分为 24 份,这便是二十四节气,每一段大约为 15.2 天。

一个有趣的小知识:由于岁差的影响,极星也会发生变化。现在的北极星是位于小熊座的勾陈一,而两千和四千年后分别会变为仙王座的两颗星,在14000年后又会变为位于天琴座的织女星,在25000年后勾陈一会再次成为北极星。

星空和时间是浪漫与想象的集合,在广袤的未知中蕴藏着无限的可能。宏大的星图卷轴在每一个夜空中徐徐展开,足以令人陶醉其中而无法自拔。面对浩瀚的星宇,我们自己只是历史长河中的一瞬,是有一天终会被忘记的尘埃。所以我们过去做了什么并不重要,我们将来如何被记住也不重要,唯一重要的,就是此时此刻。

2026年4月更新,原写于2023年9月30日。