Files
hunjing/backend/app/modules/time_sense/lunar.py
T
2026-08-21 16:00:13 +08:00

217 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""农历/节气/节日/时辰引擎。
依赖 ``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