计算给定日期前后的节气具体时间

从小到大,每到过年前使用农历的频次会显著增多,比如腊八、小年、除夕、初一,还有腊月的年俗。注意我们使用的农历不是阴历也不是阳历,而是阴阳合历。农历是根据月相的变化周期,每一次月相朔望变化为一个月,参考太阳回归年为一年的长度,并加入二十四节气与设置闰月以使平均历年与回归年相适应。

采用阴阳合历主要是要让农业生产配合对的农时,保证最大化利用自然天气变化,保证一年的农业收成。那么,如何比较准确的计算农历呢?这里面不得不说二十四节气。一首耳熟能详的歌谣,“春雨惊春清谷天,夏满芒夏暑相连。秋处露秋寒霜降,冬雪雪冬小大寒。”二十四节气对应的都是重要的农时。之前也就是当作一首歌谣来记得,但是要想知道每个节气具体到来的时间,除了查黄历,要准确计算该怎么办呢?

廿四节气原依据北斗七星的斗转星移制定,即所谓“斗柄指东,天下皆春;斗柄指南,天下皆夏;斗柄指西,天下皆秋;斗柄指北,天下皆冬”的星象规律。

现行的“二十四节气”来自于三百多年前依据太阳黄经度数划分的方法,自1645年起沿用至今。“二十四节气”是根据太阳在回归黄道上的位置(太阳黄经度数)来定的,即在一个为360度圆周的“黄道”(一年当中太阳在天球上的视路径)上,划分为24等份,每15°一等份,以春分点为0度起点,按黄经度数编排。即,视太阳从春分点,也就是黄经零度出发,此刻太阳垂直照射赤道,每前进15度为一个节气;运行一周又回到春分点,为一回归年,24个节气正好360度,太阳在黄道上每运行15度为一个“节气”。

因此,除了古人使用的北斗七星斗柄指向、“土圭测影”等方法,以及民间一些特殊现象的经验性总结,使用太阳黄经划分是最精确的方法。

接下来,我们该如何获得太阳黄经呢?传统方法是根据天文记录和官方给出的星图,比如NASA,南京紫金山天文台等专业机构。

Ephem是一个用于计算天文学相关信息的Python库,它提供了丰富的功能来处理天文数据,包括恒星位置、日月位置、行星位置、星座边界、日出日落时间等。这个库最初由B.C. Mills开发,后来被Paul Schlyter扩展和更新。

以计算给定日期(公历本地时间)的前一个和后一个节气为例,我们的计算过程大概可以分为几步,首先将根据日期计算得到对应的太阳黄经度数,然后以15度为一个节气,计算出前一个和后一个节气的目标太阳黄经度数,接下来以当前时间为起点,搜索达到目标经度时的本地时间。

由于Ephem库使用的是UT时间,注意先将本地时间换算到UT时间,如北京时间-8小时。

由于一年中节对的是农历的12个月,因此我们以找节为例,找气类似。第一步和第二步比较简单,第三步稍微复杂一些。下面再深入讲讲思路,以找当前日期的后一个节时间为例。

确定目标经度后,从当前日期/经度开始,求目标经度的时间呢?已知是当前日期,当前经度,目标经度,求目标节时间。最简单的方法就是模拟天体运动,转到对应的角度。那就相当于从当前日期开始,以一个步长推进时间,然后计算模拟推进日期的经度,如果达到目标经度,那此时的时间就是节时间。

按照这个思路实现后,发现解的节时间精度不够,对比紫金山天文台给的数据最多的时候能差出来2天。那怎么办?查了查资料,提高精度的方法也很简单,在上述方法的基础上,在初步节时间基础上,再在一个小范围内搜索。第二阶段的搜索,由于搜索范围缩小了,为了提高效率,可以不再模拟小步长推进,而是使用二分查找。划一个搜索范围,看中点时间的经度,如果比目标经度小,那就再中点和上界中搜索,如果比目标经度大,那就再下界和中点中搜索。迭代多次。

经过这样一个折腾,得到的节气时间能够精确的分钟。这个精度对于自用,或者基于此做万年历给节气时间足够了,毕竟不会出现天为单位偏差。但是,根据Ephem库的说明,由于使用的历表数据限制,同时使用的是快速算法,其给出的黄经精度会随着时间偏移增大而增加。

最后,放上代码供参考。

import math
import ephem
from datetime import datetime, timedelta
from typing import Union

# 节气
JIEQI = [
    "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", 
    "立夏", "小满", "芒种", "夏至", "小暑", "大暑", 
    "立秋", "处暑", "白露", "秋分", "寒露", "霜降", 
    "立冬", "小雪", "大雪", "冬至", "小寒", "大寒"]


# 中文和英文混杂的对齐有问题,需要特殊的填充
def get_chinese_char_number(char):
    count = 0
    for item in char:
       # chinese char and chinese punctuation mark
        if 0x4E00 <= ord(item) <= 0x9FA5 or 0xFF00 <= ord(item) <=0xFFEF or 0x3000 <= ord(item) <= 0x303F:
            count += 1
    return count


def format_with_padding(char, align, length):
    if align=='l' or align=='left' :
        anchor = '<'
    elif align=='c' or align=='center':
        anchor = '^'
    elif align=='r' or align=='right':
        anchor = '>'
    else:
        ut.print_error('Not support align type. Current support is l(left), c(center), r(right)')
        return -1
    add_len = get_chinese_char_number(char)
    if add_len >= length:
        p_len = 0
    else:
        p_len = length - add_len
    return f'{char:{anchor}{p_len}}'


# 使用Ephem计算节气, 准确. 偏差在几个小时
# 计算太阳黄经hlon
def get_sun_longitude_hlon(date, observer):
    observer.date = date
    sun = ephem.Sun(observer)
    sun_lon = sun.hlon * 180 / ephem.pi  # 转换为度数
    return sun_lon % 360  # 确保在 0-360范围内


# 计算上一个节气
def find_previous_jieqi_hlon(date, observer):
    current_date = date
    cur_lon = get_sun_longitude_hlon(current_date, observer)
    # print(current_date, cur_lon)

    jieqi_index = int(cur_lon) // 15 - 1 if int(cur_lon) % 15 >= 0 else 0
    jieqi_index = jieqi_index % 24
    if jieqi_index%2 == 0: # 修正到节, 立秋315度为节, 对应21, 为奇数. 因此, 遇到偶数是气, 需要找到下一个节.
        jieqi_index -= 1

    # print(jieqi_index)
    target_lon = (jieqi_index) * 15 # 目标黄经(0起每15一个节气)
    # print(target_lon)

    # 第一阶段:找到黄经首次超过目标值的日期
    date0 = ephem.Date(f"{date.year}/{date.month}/{date.day}")
    # print(ephem_to_datetime(date0))
    observer0 = ephem.Observer()
    observer0.lat = observer.lat
    observer0.lon = observer.lon

    while True:
        current_lon = get_sun_longitude_hlon(date0, observer0) # 计算视黄经
        # print(ephem_to_datetime(date0), current_lon)

        if abs(current_lon - target_lon) < 0.1:
            break
        date0 -= 1./24  # 逐小时逼近

    # print('First phase', ephem_to_datetime(date0))

    # 第二阶段:二分法精确到秒
    lower = date0 - 2  # 前一天
    upper = date0 + 2  # 后两天
    # print(ephem_to_datetime(lower), ephem_to_datetime(upper))
    for _ in range(40):  # 20次二分可达约0.1秒精度
        mid = (lower + upper) * 0.5
        current_lon = get_sun_longitude_hlon(mid, observer0) # 计算视黄经
        # print(ephem_to_datetime(mid), current_lon)

        if current_lon < target_lon:
            lower = mid
        else:
            upper = mid

    # print('Second phase', ephem_to_datetime(lower), ephem_to_datetime(mid), ephem_to_datetime(upper))
    jieqi_index = (jieqi_index-9)%24
    jieqi = JIEQI[jieqi_index] # offset
    jieqi_time = ephem_to_datetime(upper)
    return jieqi, jieqi_time, jieqi_index


# 计算下一个节气
def find_next_jieqi_hlon(date, observer):
    current_date = date
    cur_lon = get_sun_longitude_hlon(current_date, observer)
    # print(current_date, cur_lon)

    jieqi_index = int(cur_lon) // 15 + 1 if int(cur_lon) % 15 >= 0 else 0
    jieqi_index = jieqi_index % 24
    if jieqi_index%2 == 0: # 修正到节, 立秋315度为节, 对应21, 为奇数. 因此, 遇到偶数是气, 需要找到下一个节.
        jieqi_index += 1

    # print(jieqi_index)
    target_lon = (jieqi_index) * 15 # 目标黄经(0起每15一个节气)
    # print(target_lon)

    # 第一阶段:找到黄经首次超过目标值的日期
    date0 = ephem.Date(f"{date.year}/{date.month}/{date.day}")
    # print(ephem_to_datetime(date0))
    observer0 = ephem.Observer()
    observer0.lat = observer.lat
    observer0.lon = observer.lon

    while True:
        current_lon = get_sun_longitude_hlon(date0, observer0) # 计算视黄经
        # print(ephem_to_datetime(date0), current_lon)

        if abs(current_lon - target_lon) < 0.1:
            break
        date0 += 1./24  # 逐小时逼近

    # print('First phase', ephem_to_datetime(date0))

    # 第二阶段:二分法精确到秒
    lower = date0 - 2  # 前一天
    upper = date0 + 2  # 后两天
    # print(ephem_to_datetime(lower), ephem_to_datetime(upper))
    for _ in range(40):  # 20次二分可达约0.1秒精度
        mid = (lower + upper) * 0.5
        current_lon = get_sun_longitude_hlon(mid, observer0) # 计算视黄经
        # print(ephem_to_datetime(mid), current_lon)

        if current_lon < target_lon:
            lower = mid
        else:
            upper = mid

    # print('Second phase', ephem_to_datetime(lower), ephem_to_datetime(mid), ephem_to_datetime(upper))
    jieqi_index = (jieqi_index-9)%24 # offset
    jieqi = JIEQI[jieqi_index]
    jieqi_time = ephem_to_datetime(upper)
    return jieqi, jieqi_time, jieqi_index


"""计算指定年份的二十四节气时间"""
def jq_new_hlon(date, observer, order):
    # 获取上一个和下一个节气
    jieqi = None
    jieqi_time = None
    jieqi_index = 0

    if order == -1:
        jieqi, jieqi_time, jieqi_index = find_previous_jieqi_hlon(date, observer)
        # print('prev', prev_jieqi, prev_jieqi_time)
    else:
        jieqi, jieqi_time, jieqi_index = find_next_jieqi_hlon(date, observer)
        # print('next', next_jieqi, next_jieqi_time)

    return jieqi, jieqi_time, jieqi_index


# 时间转换
# 获取当前环境的时区偏移值
def cur_time_zone_offset() -> float:
    current_time = datetime.now()
    # 获取UTC时间
    utc_time = datetime.utcnow()
    # 计算小时差
    time_zone_offset = round((current_time - utc_time).seconds / 3600.0, 2)
    # 以0.5为基准进行舍入操作:
    if time_zone_offset >= 0:
        time_zone_offset = math.floor(time_zone_offset / 0.5 + 0.5) * 0.5
    else: 
        time_zone_offset = math.ceil(time_zone_offset / 0.5 - 0.5) * 0.5
    
    return time_zone_offset


# 计算指定本地时间的真太阳时
def calculate_true_solar_time(
    date_local: datetime, 
    observer_latitude: Union[str, float]=0, 
    observer_longitude: Union[str, float]=0) -> datetime:
    """
    根据观测者的经纬度和时间, 计算真太阳时.
    参数:
    date_local (datetime): 观察时间
    observer_latitude (str): 观测者的纬度, 以度为单位.
    observer_longitude (str): 观测者的经度, 以度为单位.
    返回:
    datetime: 调整后的真太阳时, 已考虑观测者的经纬度和时区偏移量.
    """

    # 根据观测者的经纬度和时间, 计算真太阳时:
    # 计算当前时区偏移time_zone_offset
    time_zone_offset = cur_time_zone_offset()    
    # 将观测者的纬度和经度转换为ephem格式
    lat, lon = ephem.degrees(str(observer_latitude)), ephem.degrees(str(observer_longitude))
 
    # 创建一个新的观测者对象
    observer = ephem.Observer()
    observer.lat, observer.lon = lat, lon
 
    # 将输入的当地时间转换为ephem的Date格式
    date_0 = date_local - timedelta(hours=time_zone_offset)# 将本地时间转换为UTC+0时间
    utc_date = ephem.Date(date_0)
 
    # 设置观测者的日期和时间
    observer.date = utc_date
 
    # 计算太阳的位置
    sun = ephem.Sun(observer)
    # 计算本地太阳正午时间(在给定日期太阳通过当地子午线的时间)
    next_noon = observer.next_transit(sun).datetime() + timedelta(hours=time_zone_offset)
    # 计算钟表正午时间
    clock_noon = next_noon.replace(hour=12, minute=0, second=0, microsecond=0)
    # 计算太阳正午和钟表正午之间的差值
    time_diff = (clock_noon - next_noon).total_seconds() / 3600.0
    # 调整本地时间以获得真太阳时(ephem通常已经考虑光行差)
    true_solar_time = date_local + timedelta(hours=time_diff)
    return true_solar_time


# 将ephem时间转成datetime时间, 注意都是UT时间, ephem.localtime函数会出问题
def ephem_to_datetime(date_ephem):
    # ephem的基准日期(1899-12-31 12:00:00 UTC)
    EPOCH = datetime(1899, 12, 31, 12, 0, 0)

    delta = timedelta(days=date_ephem)
    date_ut = EPOCH + delta

    return date_ut


date_str = '2025-2-1 0:35:30'
observer_latitude, observer_longitude = '39.9', '110.8333'

date_test = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')

observer = ephem.Observer()
observer.lat = observer_latitude
observer.lon = observer_longitude

true_solar_time = calculate_true_solar_time(date_test, observer_latitude, observer_longitude)
date_ut = true_solar_time - timedelta(hours=8)

jq_prev, jqdate_prev, jq_index = jq_new_hlon(date_ut, observer, -1) # 需要UT时间, 不是本地时间
jq_next, jqdate_next, jq_index = jq_new_hlon(date_ut, observer, 1) # 需要UT时间, 不是本地时间

jqdate_prev += timedelta(hours=8)
jqdate_next += timedelta(hours=8)

print(date_test.strftime('%Y-%m-%d %H:%M:%S'), '前一个节是')
print(format_with_padding(jq_prev, 'l', 10)+format_with_padding(jqdate_prev.strftime('%Y-%m-%d %H:%M:%S'), 'l', 10))
print(date_test.strftime('%Y-%m-%d %H:%M:%S'), '后一个节是')
print(format_with_padding(jq_next, 'l', 10)+format_with_padding(jqdate_next.strftime('%Y-%m-%d %H:%M:%S'), 'l', 10))

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值