"""农历/节气/节日/时辰引擎。 依赖 ``lunar_python``(寿星天文历 Python 版)做历法换算与节气判定;节日按 公历固定日期 + 农历日期两张表命中。全部纯函数,无副作用。 """ from __future__ import annotations from datetime import date from lunar_python import Solar from pydantic import BaseModel class LunarDate(BaseModel): """农历日期。""" year_ganzhi: str month: int day: int is_leap_month: bool class SolarTermInfo(BaseModel): """节气信息。""" current_name: str | None next_name: str days_to_next: int class Festival(BaseModel): """节日命中。""" name: str calendar: str # solar / lunar locale: str = "zh-CN" class Shichen(BaseModel): """十二时辰。""" name: str start_hour: int end_hour: int # 十二时辰:name -> (start, end),跨零点的时辰 end < start(子时 23->1) _SHICHEN_TABLE: list[tuple[str, int, int]] = [ ("子", 23, 1), ("丑", 1, 3), ("寅", 3, 5), ("卯", 5, 7), ("辰", 7, 9), ("巳", 9, 11), ("午", 11, 13), ("未", 13, 15), ("申", 15, 17), ("酉", 17, 19), ("戌", 19, 21), ("亥", 21, 23), ] # 公历固定节日(month, day) -> name _SOLAR_FESTIVALS: dict[tuple[int, int], str] = { (1, 1): "元旦", (2, 14): "情人节", (5, 1): "劳动节", (10, 1): "国庆节", (12, 25): "圣诞节", } # lunar_python 的 getJieQiTable() 对跨年带入的节气用大写拼音常量作 key # (实证于 lunar_python 1.4.8:DA_HAN/DONG_ZHI/JING_ZHE/LI_CHUN/XIAO_HAN/YU_SHUI 等), # 本表内节气用中文 key。此处把 24 节气拼音常量统一翻成中文,已是中文的保持不变。 _JIEQI_PINYIN_TO_CN: dict[str, str] = { "LI_CHUN": "立春", "YU_SHUI": "雨水", "JING_ZHE": "惊蛰", "CHUN_FEN": "春分", "QING_MING": "清明", "GU_YU": "谷雨", "LI_XIA": "立夏", "XIAO_MAN": "小满", "MANG_ZHONG": "芒种", "XIA_ZHI": "夏至", "XIAO_SHU": "小暑", "DA_SHU": "大暑", "LI_QIU": "立秋", "CHU_SHU": "处暑", "BAI_LU": "白露", "QIU_FEN": "秋分", "HAN_LU": "寒露", "SHUANG_JIANG": "霜降", "LI_DONG": "立冬", "XIAO_XUE": "小雪", "DA_XUE": "大雪", "DONG_ZHI": "冬至", "XIAO_HAN": "小寒", "DA_HAN": "大寒", } # 农历节日(lunar_month, lunar_day, is_leap) -> name _LUNAR_FESTIVALS: dict[tuple[int, int, bool], str] = { (1, 1, False): "春节", (1, 15, False): "元宵节", (5, 5, False): "端午节", (7, 7, False): "七夕", (8, 15, False): "中秋节", (9, 9, False): "重阳节", (12, 8, False): "腊八节", } def solar_to_lunar(solar_date: date) -> LunarDate: """公历日期转农历日期。 ``lunar_python`` 的 ``getMonth()`` 对闰月返回负数(如 -6 表示闰六月), 非闰月返回正数;据此推导 ``is_leap_month`` 与展示用的月份数值。 """ solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day) lunar = solar.getLunar() raw_month = lunar.getMonth() return LunarDate( year_ganzhi=lunar.getYearInGanZhi(), month=abs(raw_month), day=int(lunar.getDay()), is_leap_month=raw_month < 0, ) def get_shichen(hour: int) -> Shichen: """按 24 小时制小时返回十二时辰。子时跨零点(23 与 0 均属子)。""" h = hour % 24 for name, start, end in _SHICHEN_TABLE: if start < end: if start <= h < end: return Shichen(name=name, start_hour=start, end_hour=end) else: # 跨零点(子时 23->1) if h >= start or h < end: return Shichen(name=name, start_hour=start, end_hour=end) # 理论不可达(24 小时全覆盖) raise ValueError(f"无法识别时辰:hour={hour}") def get_day_period(hour: int) -> str: """返回现代时段枚举值(契约字典 day_period)。""" h = hour % 24 if 0 <= h < 5: return "凌晨" if 5 <= h < 8: return "清晨" if 8 <= h < 11: return "上午" if 11 <= h < 13: return "中午" if 13 <= h < 17: return "下午" if 17 <= h < 19: return "傍晚" if 19 <= h < 23: return "夜晚" return "深夜" # 23-24 def get_solar_term(solar_date: date) -> SolarTermInfo: """返回当日命中的节气与下一节气信息。 ``lunar_python`` 的 ``getJieQiTable()`` 返回 dict(节气名 -> ``Solar``); 值本身即 ``Solar`` 时刻对象,无需再调 ``getSolar()``。据此判定当日是否 命中某节气,并算出距下一节气的天数。 """ solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day) lunar = solar.getLunar() jieqi = lunar.getJieQiTable() # dict: 节气名 -> Solar 时刻 current_name: str | None = None for raw_name, moment in jieqi.items(): if ( moment.getYear() == solar_date.year and moment.getMonth() == solar_date.month and moment.getDay() == solar_date.day ): current_name = _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name) break # 下一节气:从所有节气时刻中找当前日期之后最近的 upcoming: list[tuple[int, str, date]] = [] for raw_name, moment in jieqi.items(): d = date(moment.getYear(), moment.getMonth(), moment.getDay()) if d > solar_date: upcoming.append(((d - solar_date).days, _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name), d)) upcoming.sort() days_to_next, next_name, _ = upcoming[0] return SolarTermInfo(current_name=current_name, next_name=next_name, days_to_next=days_to_next) def get_festivals(solar_date: date, locale: str = "zh-CN") -> list[Festival]: """命中今日的公历与农历节日。""" result: list[Festival] = [] # 公历 if (solar_date.month, solar_date.day) in _SOLAR_FESTIVALS: result.append( Festival( name=_SOLAR_FESTIVALS[(solar_date.month, solar_date.day)], calendar="solar", locale=locale, ) ) # 农历 lunar = solar_to_lunar(solar_date) key = (lunar.month, lunar.day, lunar.is_leap_month) if key in _LUNAR_FESTIVALS: result.append(Festival(name=_LUNAR_FESTIVALS[key], calendar="lunar", locale=locale)) return result