fixed_dates.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """确定性节日/节气/纪念日的代码日期计算与活跃检测兜底。"""
  2. from __future__ import annotations
  3. from datetime import date, timedelta
  4. from typing import Any
  5. from app.festival_demand.validation import filter_festivals_by_query_date
  6. # 21 世纪节气常数 C(索引 0=小寒 … 23=冬至)
  7. _CENTURY_21_C = (
  8. 5.4055,
  9. 20.12,
  10. 3.87,
  11. 18.73,
  12. 5.63,
  13. 20.646,
  14. 4.81,
  15. 20.1,
  16. 5.52,
  17. 21.04,
  18. 5.678,
  19. 21.37,
  20. 7.108,
  21. 22.83,
  22. 7.5,
  23. 23.13,
  24. 7.646,
  25. 23.042,
  26. 8.318,
  27. 23.438,
  28. 7.438,
  29. 22.36,
  30. 7.18,
  31. 21.94,
  32. )
  33. _SOLAR_TERM_INDEX: dict[str, int] = {
  34. "小寒": 0,
  35. "大寒": 1,
  36. "立春": 2,
  37. "雨水": 3,
  38. "惊蛰": 4,
  39. "春分": 5,
  40. "清明": 6,
  41. "谷雨": 7,
  42. "立夏": 8,
  43. "小满": 9,
  44. "芒种": 10,
  45. "夏至": 11,
  46. "小暑": 12,
  47. "大暑": 13,
  48. "立秋": 14,
  49. "处暑": 15,
  50. "白露": 16,
  51. "秋分": 17,
  52. "寒露": 18,
  53. "霜降": 19,
  54. "立冬": 20,
  55. "小雪": 21,
  56. "大雪": 22,
  57. "冬至": 23,
  58. }
  59. # 固定公历月日(非法定单日事件:festival_start = festival_end = 当天)
  60. FIXED_MONTH_DAY: dict[str, tuple[int, int]] = {
  61. "妇女节": (3, 8),
  62. "儿童节": (6, 1),
  63. "建党节": (7, 1),
  64. "建军节": (8, 1),
  65. "教师节": (9, 10),
  66. "周总理逝世": (1, 8),
  67. "周总理诞辰": (3, 5),
  68. "七七事变纪念日": (7, 7),
  69. "日本投降": (8, 15),
  70. "毛主席逝世": (9, 9),
  71. "918纪念日": (9, 18),
  72. "毛主席诞辰": (12, 26),
  73. "公祭日": (12, 13),
  74. "台湾光复纪念日": (10, 25),
  75. "315": (3, 15),
  76. }
  77. # 固定公历锚点日(法定节日兜底:仅锚点日当天为节日期,放假区间仍由 LLM 补充)
  78. FIXED_STATUTORY_ANCHOR: dict[str, tuple[int, int]] = {
  79. "元旦": (1, 1),
  80. "劳动节": (5, 1),
  81. "国庆节": (10, 1),
  82. }
  83. # 第 N 个星期 X(month 1-12, weekday 0=周一 … 6=周日)
  84. _NTH_WEEKDAY_RULES: dict[str, tuple[int, int, int]] = {
  85. "母亲节": (5, 6, 2), # 5 月第 2 个周日
  86. "父亲节": (6, 6, 3), # 6 月第 3 个周日
  87. }
  88. DETERMINISTIC_EVENT_NAMES: frozenset[str] = frozenset(
  89. {
  90. *_SOLAR_TERM_INDEX.keys(),
  91. *FIXED_MONTH_DAY.keys(),
  92. *FIXED_STATUTORY_ANCHOR.keys(),
  93. *_NTH_WEEKDAY_RULES.keys(),
  94. }
  95. )
  96. def is_deterministic_event(name: str) -> bool:
  97. return name in DETERMINISTIC_EVENT_NAMES
  98. def solar_term_date(year: int, term_name: str) -> date:
  99. """计算指定年份的二十四节气公历日期。"""
  100. if term_name not in _SOLAR_TERM_INDEX:
  101. raise ValueError(f"unknown solar term: {term_name}")
  102. term_index = _SOLAR_TERM_INDEX[term_name]
  103. century_c = _CENTURY_21_C[term_index]
  104. y = year % 100
  105. leap_years = y // 4
  106. day = int(y * 0.2422 + century_c) - leap_years
  107. month = term_index // 2 + 1
  108. return date(year, month, day)
  109. def nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> date:
  110. """返回某月第 nth 个指定星期几的日期(weekday: 0=周一 … 6=周日)。"""
  111. if nth <= 0:
  112. raise ValueError("nth must be positive")
  113. first = date(year, month, 1)
  114. offset = (weekday - first.weekday()) % 7
  115. candidate = first + timedelta(days=offset)
  116. candidate += timedelta(weeks=nth - 1)
  117. if candidate.month != month:
  118. raise ValueError("nth weekday does not exist in month")
  119. return candidate
  120. def resolve_event_date(year: int, name: str) -> date | None:
  121. """解析确定性事件的当年公历日期。"""
  122. if name in _SOLAR_TERM_INDEX:
  123. return solar_term_date(year, name)
  124. if name in FIXED_MONTH_DAY:
  125. month, day = FIXED_MONTH_DAY[name]
  126. return date(year, month, day)
  127. if name in FIXED_STATUTORY_ANCHOR:
  128. month, day = FIXED_STATUTORY_ANCHOR[name]
  129. return date(year, month, day)
  130. if name in _NTH_WEEKDAY_RULES:
  131. month, weekday, nth = _NTH_WEEKDAY_RULES[name]
  132. return nth_weekday_of_month(year, month, weekday, nth)
  133. return None
  134. def detect_deterministic_active_festivals(
  135. query_date: date,
  136. *,
  137. allowed_kinds: dict[str, str],
  138. prewarm_by_name: dict[str, int],
  139. event_type_by_name: dict[str, str],
  140. ) -> list[dict[str, Any]]:
  141. """用代码计算确定性事件在 query_date 是否处于预热/节日期。"""
  142. candidates: list[dict[str, Any]] = []
  143. year = query_date.year
  144. for name in DETERMINISTIC_EVENT_NAMES:
  145. if name not in prewarm_by_name:
  146. continue
  147. try:
  148. event_date_value = resolve_event_date(year, name)
  149. except (ValueError, OverflowError):
  150. continue
  151. if event_date_value is None:
  152. continue
  153. kind = allowed_kinds.get(name, "non_statutory")
  154. event_iso = event_date_value.isoformat()
  155. candidates.append(
  156. {
  157. "name": name,
  158. "kind": kind,
  159. "event_date": event_iso,
  160. "festival_start": event_iso,
  161. "festival_end": event_iso,
  162. }
  163. )
  164. filtered = filter_festivals_by_query_date(
  165. candidates,
  166. query_date,
  167. prewarm_by_name,
  168. )
  169. enriched: list[dict[str, Any]] = []
  170. for item in filtered:
  171. name = str(item.get("name") or "").strip()
  172. enriched.append(
  173. {
  174. **item,
  175. "event_type": event_type_by_name.get(name, ""),
  176. "detection_source": "code",
  177. "reason": "代码兜底:确定性日期",
  178. }
  179. )
  180. return enriched
  181. def merge_festival_detections(
  182. *detection_lists: list[dict[str, Any]],
  183. ) -> list[dict[str, Any]]:
  184. """按名称合并多路检测结果,后出现的结果覆盖先前的同名项。"""
  185. merged: dict[str, dict[str, Any]] = {}
  186. for detection_list in detection_lists:
  187. for item in detection_list:
  188. name = str(item.get("name") or "").strip()
  189. if not name:
  190. continue
  191. merged[name] = item
  192. return list(merged.values())