ziwei 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ziwei/__init__.py +1 -0
- ziwei/birth.py +253 -0
- ziwei/bureau.py +54 -0
- ziwei/calendar.py +305 -0
- ziwei/chart.py +208 -0
- ziwei/config.py +86 -0
- ziwei/decades.py +112 -0
- ziwei/ganzhi.py +289 -0
- ziwei/knowledge/__init__.py +17 -0
- ziwei/knowledge/catalog.py +232 -0
- ziwei/knowledge/data.py +1452 -0
- ziwei/knowledge/model.py +331 -0
- ziwei/palaces.py +151 -0
- ziwei/py.typed +0 -0
- ziwei/render.py +64 -0
- ziwei/resolver.py +171 -0
- ziwei/serialization.py +409 -0
- ziwei/sihua.py +155 -0
- ziwei/stars.py +475 -0
- ziwei/timezone.py +176 -0
- ziwei-0.1.0.dist-info/METADATA +93 -0
- ziwei-0.1.0.dist-info/RECORD +25 -0
- ziwei-0.1.0.dist-info/WHEEL +5 -0
- ziwei-0.1.0.dist-info/licenses/LICENSE +21 -0
- ziwei-0.1.0.dist-info/top_level.txt +1 -0
ziwei/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""紫微斗数排盘与知识库。"""
|
ziwei/birth.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""原始出生记录与解析后计算输入;本模块不转换公历和农历。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, time
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from ziwei.calendar import LunarAlgorithm
|
|
8
|
+
from ziwei.config import ChartConfig, DayRollover, YearBoundary
|
|
9
|
+
from ziwei.ganzhi import Dizhi, Ganzhi, Yinyang, year_ganzhi
|
|
10
|
+
from ziwei.timezone import FixedOffset, IanaTimezone, ResolvedTime, resolve_civil_time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Gender(StrEnum):
|
|
14
|
+
"""出生记录中的性别,未知不代填。"""
|
|
15
|
+
|
|
16
|
+
MALE = "男"
|
|
17
|
+
FEMALE = "女"
|
|
18
|
+
UNKNOWN = "未知"
|
|
19
|
+
男 = MALE
|
|
20
|
+
女 = FEMALE
|
|
21
|
+
未知 = UNKNOWN
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TimePrecision(StrEnum):
|
|
25
|
+
"""原始民用钟点的记录精度;仅有日期时使用日。"""
|
|
26
|
+
|
|
27
|
+
DAY = "日"
|
|
28
|
+
HOUR = "时"
|
|
29
|
+
MINUTE = "分"
|
|
30
|
+
SECOND = "秒"
|
|
31
|
+
MICROSECOND = "微秒"
|
|
32
|
+
日 = DAY
|
|
33
|
+
时 = HOUR
|
|
34
|
+
分 = MINUTE
|
|
35
|
+
秒 = SECOND
|
|
36
|
+
微秒 = MICROSECOND
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def shichen_from_time(clock: time) -> Dizhi:
|
|
40
|
+
"""按无时区民用钟面取时辰;不改变日期或应用换日口径。
|
|
41
|
+
|
|
42
|
+
时辰范围取香港天文台《天干和地支》表三,子时跨23:00至次日01:00;本接口采用左闭右开区间。
|
|
43
|
+
"""
|
|
44
|
+
if type(clock) is not time:
|
|
45
|
+
raise TypeError("钟点须为 time")
|
|
46
|
+
if clock.tzinfo is not None or clock.fold != 0:
|
|
47
|
+
raise ValueError("钟点不得携带时区或 fold")
|
|
48
|
+
return tuple(Dizhi)[((clock.hour + 1) // 2) % 12]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_metadata(longitude_deg: float | None, place: str | None, source: str | None) -> None:
|
|
52
|
+
if longitude_deg is not None:
|
|
53
|
+
if isinstance(longitude_deg, bool) or not isinstance(longitude_deg, int | float):
|
|
54
|
+
raise TypeError("经度须为数值或 None")
|
|
55
|
+
if not -180 <= longitude_deg <= 180:
|
|
56
|
+
raise ValueError("经度须为-180至180之间的有限值,东经为正")
|
|
57
|
+
for value in (place, source):
|
|
58
|
+
if value is not None and not isinstance(value, str):
|
|
59
|
+
raise TypeError("地点与来源说明须为字符串或 None")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_lunar_date(
|
|
63
|
+
year: int, month: int, day: int, is_leap_month: bool, shichen: Dizhi | None
|
|
64
|
+
) -> None:
|
|
65
|
+
for value, maximum, name in ((year, 9999, "年"), (month, 12, "月"), (day, 30, "日")):
|
|
66
|
+
if type(value) is not int:
|
|
67
|
+
raise TypeError(f"农历{name}须为整数,不接受 bool")
|
|
68
|
+
if not 1 <= value <= maximum:
|
|
69
|
+
raise ValueError(f"农历{name}须在1至{maximum}之间")
|
|
70
|
+
if type(is_leap_month) is not bool:
|
|
71
|
+
raise TypeError("闰月标志须为 bool")
|
|
72
|
+
if shichen is not None and not isinstance(shichen, Dizhi):
|
|
73
|
+
raise TypeError("时辰须为 Dizhi 成员或 None")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
77
|
+
class GregorianBirthRecord:
|
|
78
|
+
"""原始公历日期、钟点、精度与时区;不携带派生的年干支。
|
|
79
|
+
|
|
80
|
+
birth_time=None 表示钟点未知,精度须为日。经度仅保存,不在此执行真太阳时换算。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
birth_date: date
|
|
84
|
+
birth_time: time | None
|
|
85
|
+
time_precision: TimePrecision
|
|
86
|
+
timezone: IanaTimezone | FixedOffset
|
|
87
|
+
gender: Gender
|
|
88
|
+
longitude_deg: float | None = None
|
|
89
|
+
place: str | None = None
|
|
90
|
+
source: str | None = None
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
if type(self.birth_date) is not date:
|
|
94
|
+
raise TypeError("公历日期须为 date,不接受 datetime")
|
|
95
|
+
if not isinstance(self.time_precision, TimePrecision):
|
|
96
|
+
raise TypeError("精度须为 TimePrecision 成员")
|
|
97
|
+
if not isinstance(self.timezone, IanaTimezone | FixedOffset):
|
|
98
|
+
raise TypeError("时区须为 IanaTimezone 或 FixedOffset")
|
|
99
|
+
if not isinstance(self.gender, Gender):
|
|
100
|
+
raise TypeError("性别须为 Gender 成员")
|
|
101
|
+
_check_metadata(self.longitude_deg, self.place, self.source)
|
|
102
|
+
if self.birth_time is None:
|
|
103
|
+
if self.time_precision is not TimePrecision.日:
|
|
104
|
+
raise ValueError("钟点未知时精度须为日")
|
|
105
|
+
return
|
|
106
|
+
shichen_from_time(self.birth_time)
|
|
107
|
+
if self.time_precision is TimePrecision.日:
|
|
108
|
+
raise ValueError("日精度的记录不得携带钟点")
|
|
109
|
+
components = (self.birth_time.minute, self.birth_time.second, self.birth_time.microsecond)
|
|
110
|
+
lower = {
|
|
111
|
+
TimePrecision.时: components,
|
|
112
|
+
TimePrecision.分: components[1:],
|
|
113
|
+
TimePrecision.秒: components[2:],
|
|
114
|
+
TimePrecision.微秒: (),
|
|
115
|
+
}
|
|
116
|
+
if any(lower[self.time_precision]):
|
|
117
|
+
raise ValueError("钟点包含低于所声明记录精度的分量")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def shichen(self) -> Dizhi | None:
|
|
121
|
+
"""原始民用钟面的时辰;未应用真太阳时或换日口径。"""
|
|
122
|
+
return None if self.birth_time is None else shichen_from_time(self.birth_time)
|
|
123
|
+
|
|
124
|
+
def resolve_time(self) -> ResolvedTime:
|
|
125
|
+
"""消歧民用钟点;未知钟点或不支持的历史时标显式失败。"""
|
|
126
|
+
if self.birth_time is None:
|
|
127
|
+
raise ValueError("出生钟点未知,不能解析民用时刻")
|
|
128
|
+
return resolve_civil_time(datetime.combine(self.birth_date, self.birth_time), self.timezone)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
132
|
+
class LunarBirthRecord:
|
|
133
|
+
"""原始农历年月日、闰月标志与时辰;只核值域,不核该月存在性及月长。"""
|
|
134
|
+
|
|
135
|
+
year: int
|
|
136
|
+
month: int
|
|
137
|
+
day: int
|
|
138
|
+
is_leap_month: bool
|
|
139
|
+
shichen: Dizhi | None
|
|
140
|
+
gender: Gender
|
|
141
|
+
longitude_deg: float | None = None
|
|
142
|
+
place: str | None = None
|
|
143
|
+
source: str | None = None
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
_check_lunar_date(self.year, self.month, self.day, self.is_leap_month, self.shichen)
|
|
147
|
+
if not isinstance(self.gender, Gender):
|
|
148
|
+
raise TypeError("性别须为 Gender 成员")
|
|
149
|
+
_check_metadata(self.longitude_deg, self.place, self.source)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
BirthRecord = GregorianBirthRecord | LunarBirthRecord
|
|
153
|
+
"""公历与农历的原始出生记录,保留各自不同的输入信息。"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
157
|
+
class CalculationInput:
|
|
158
|
+
"""解析层的输出形状,由resolver.resolve从原始记录生成。
|
|
159
|
+
|
|
160
|
+
ganzhi_year 是解析层按所选换年口径得到的纪年编号,不一定等于 lunar_year。
|
|
161
|
+
原始记录和实际口径随值保存;阴阳及年干支由该编号派生,不维护可互相矛盾的副本。
|
|
162
|
+
原始时辰未知时,解析时辰不得变为已知。
|
|
163
|
+
lunar_algorithm 与 lunar_algorithm_version 记录所用农历算法及 celestial 版本;
|
|
164
|
+
apparent_solar 记录是否采用真太阳时路径;day_rolled 记录晚子时归次日是否顺延;
|
|
165
|
+
ut1_jd 与 lichun_jd 记录立春换年所比较的出生瞬间与立春时刻(UT1 日数),
|
|
166
|
+
纯民用正月初一路径为 None。
|
|
167
|
+
resolved_time保留解析层实际取得的钟面、UTC、偏移和时区数据版本;没有取得瞬间时为None。
|
|
168
|
+
为兼容直接构造,对resolved_time只检查它是ResolvedTime或None;完整盘另核其存在性及与原始记录的关系。
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
record: BirthRecord
|
|
172
|
+
lunar_year: int
|
|
173
|
+
month: int
|
|
174
|
+
day: int
|
|
175
|
+
is_leap_month: bool
|
|
176
|
+
shichen: Dizhi | None
|
|
177
|
+
ganzhi_year: int
|
|
178
|
+
config: ChartConfig
|
|
179
|
+
lunar_algorithm: LunarAlgorithm
|
|
180
|
+
lunar_algorithm_version: str
|
|
181
|
+
apparent_solar: bool
|
|
182
|
+
day_rolled: bool
|
|
183
|
+
ut1_jd: float | None
|
|
184
|
+
lichun_jd: float | None
|
|
185
|
+
resolved_time: ResolvedTime | None = None
|
|
186
|
+
|
|
187
|
+
def __post_init__(self) -> None:
|
|
188
|
+
if not isinstance(self.record, BirthRecord):
|
|
189
|
+
raise TypeError("原始记录须为公历或农历出生记录")
|
|
190
|
+
if self.resolved_time is not None and not isinstance(self.resolved_time, ResolvedTime):
|
|
191
|
+
raise TypeError("已解析时刻须为 ResolvedTime 或 None")
|
|
192
|
+
_check_lunar_date(self.lunar_year, self.month, self.day, self.is_leap_month, self.shichen)
|
|
193
|
+
if self.record.shichen is None and self.shichen is not None:
|
|
194
|
+
raise ValueError("原始记录的未知时辰不得变为已知")
|
|
195
|
+
year_ganzhi(self.ganzhi_year)
|
|
196
|
+
if not isinstance(self.config, ChartConfig):
|
|
197
|
+
raise TypeError("口径须为显式 ChartConfig")
|
|
198
|
+
if not isinstance(self.lunar_algorithm, LunarAlgorithm):
|
|
199
|
+
raise TypeError("农历算法须为 LunarAlgorithm 成员")
|
|
200
|
+
if not isinstance(self.lunar_algorithm_version, str) or not self.lunar_algorithm_version:
|
|
201
|
+
raise ValueError("农历算法版本不得为空")
|
|
202
|
+
for value, label in (
|
|
203
|
+
(self.apparent_solar, "真太阳时路径"),
|
|
204
|
+
(self.day_rolled, "晚子时顺延"),
|
|
205
|
+
):
|
|
206
|
+
if type(value) is not bool:
|
|
207
|
+
raise TypeError(f"{label}标志须为 bool")
|
|
208
|
+
for instant, label in ((self.ut1_jd, "出生瞬间"), (self.lichun_jd, "立春时刻")):
|
|
209
|
+
if instant is not None and not isinstance(instant, float):
|
|
210
|
+
raise TypeError(f"{label} UT1 日数须为 float 或 None")
|
|
211
|
+
if self.config.year_boundary is YearBoundary.立春 and self.ut1_jd is None:
|
|
212
|
+
raise ValueError("立春换年必须记录出生瞬间的 UT1 日数")
|
|
213
|
+
if self.ut1_jd is not None and self.lichun_jd is None:
|
|
214
|
+
raise ValueError("记录出生瞬间时必须同时记录立春时刻")
|
|
215
|
+
if self.lichun_jd is not None and self.config.year_boundary is not YearBoundary.立春:
|
|
216
|
+
raise ValueError("立春时刻只属于立春换年口径")
|
|
217
|
+
if self.day_rolled and self.shichen is not Dizhi.子:
|
|
218
|
+
raise ValueError("晚子时顺延只发生在子时")
|
|
219
|
+
if self.day_rolled and self.config.day_rollover is DayRollover.早晚子时同日:
|
|
220
|
+
raise ValueError("晚子时顺延与早晚子时同日口径矛盾")
|
|
221
|
+
if self.day_rolled:
|
|
222
|
+
if not isinstance(self.record, GregorianBirthRecord) or self.record.birth_time is None:
|
|
223
|
+
raise ValueError("晚子时顺延需要原始记录含公历钟点")
|
|
224
|
+
if not self.apparent_solar and self.record.birth_time.hour != 23:
|
|
225
|
+
raise ValueError("民用路径的晚子时顺延需要原始钟点在 23 时")
|
|
226
|
+
if self.apparent_solar and (
|
|
227
|
+
not isinstance(self.record, GregorianBirthRecord) or self.record.longitude_deg is None
|
|
228
|
+
):
|
|
229
|
+
raise ValueError("真太阳时路径需要公历记录与经度")
|
|
230
|
+
if (
|
|
231
|
+
self.config.year_boundary is YearBoundary.农历正月初一 and self.ganzhi_year != self.lunar_year
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("正月初一换年口径下干支纪年须等于农历年")
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def gender(self) -> Gender:
|
|
237
|
+
"""保留原始记录的性别,不代填未知项。"""
|
|
238
|
+
return self.record.gender
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def year_ganzhi(self) -> Ganzhi:
|
|
242
|
+
"""按已经确定的干支纪年编号查表。"""
|
|
243
|
+
return year_ganzhi(self.ganzhi_year)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def yinyang(self) -> Yinyang:
|
|
247
|
+
"""用于性别阴阳组合的年干阴阳,不作性别推断。"""
|
|
248
|
+
return self.year_ganzhi.tiangan.yinyang
|
|
249
|
+
|
|
250
|
+
def require_complete(self) -> None:
|
|
251
|
+
"""拒绝未知时辰或性别,供完整命盘入口调用;本方法不排盘。"""
|
|
252
|
+
if self.shichen is None or self.gender is Gender.未知:
|
|
253
|
+
raise ValueError("完整命盘需要已知时辰与性别")
|
ziwei/bureau.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""五行局。
|
|
2
|
+
|
|
3
|
+
五行局由命宫干支的纳音五行定:水二局、木三局、金四局、土五局、火六局。
|
|
4
|
+
《紫微斗數全書》卷二《安身命例》以「丙寅丁卯炉中火」断火局(维基文库修订1963110),
|
|
5
|
+
同页局图以水二局、木三局、金四局、土五局、火六局分列;王亭之中州派《紫微斗数初级讲义》
|
|
6
|
+
定五行局一节同样列举水二木三金四土五火六。局数即大限首限起始虚岁。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from ziwei.ganzhi import Ganzhi, Wuxing, nayin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WuxingJu(StrEnum):
|
|
15
|
+
"""具名五行局;局数即大限首限起始虚岁。"""
|
|
16
|
+
|
|
17
|
+
SHUI_ER = "水二局"
|
|
18
|
+
MU_SAN = "木三局"
|
|
19
|
+
JIN_SI = "金四局"
|
|
20
|
+
TU_WU = "土五局"
|
|
21
|
+
HUO_LIU = "火六局"
|
|
22
|
+
水二局 = SHUI_ER
|
|
23
|
+
木三局 = MU_SAN
|
|
24
|
+
金四局 = JIN_SI
|
|
25
|
+
土五局 = TU_WU
|
|
26
|
+
火六局 = HUO_LIU
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def number(self) -> int:
|
|
30
|
+
"""局数(水二木三金四土五火六),即大限首限起始虚岁。"""
|
|
31
|
+
return tuple(WuxingJu).index(self) + 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_BUREAU_BY_ELEMENT: dict[Wuxing, WuxingJu] = {
|
|
35
|
+
Wuxing.水: WuxingJu.SHUI_ER,
|
|
36
|
+
Wuxing.木: WuxingJu.MU_SAN,
|
|
37
|
+
Wuxing.金: WuxingJu.JIN_SI,
|
|
38
|
+
Wuxing.土: WuxingJu.TU_WU,
|
|
39
|
+
Wuxing.火: WuxingJu.HUO_LIU,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bureau_of(minggong_ganzhi: Ganzhi) -> WuxingJu:
|
|
44
|
+
"""由命宫干支的纳音五行定五行局。"""
|
|
45
|
+
if not isinstance(minggong_ganzhi, Ganzhi):
|
|
46
|
+
raise TypeError("五行局须查命宫干支")
|
|
47
|
+
return _BUREAU_BY_ELEMENT[nayin(minggong_ganzhi)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def bureau_number(ju: WuxingJu) -> int:
|
|
51
|
+
"""局数(水二木三金四土五火六)。"""
|
|
52
|
+
if not isinstance(ju, WuxingJu):
|
|
53
|
+
raise TypeError("五行局须为 WuxingJu 成员")
|
|
54
|
+
return ju.number
|
ziwei/calendar.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""celestial-calendar 0.8.0 的具体适配;导入本模块不初始化 celestial。
|
|
2
|
+
|
|
3
|
+
公开函数在函数体内 `import celestial_calendar`,包导入与知识查询不会加载原生历法库。
|
|
4
|
+
所选农历算法、支持范围与 celestial 版本随解析结果保存;超出支持范围或闰月/日组合
|
|
5
|
+
不存在时显式失败。
|
|
6
|
+
|
|
7
|
+
## 时标合同
|
|
8
|
+
|
|
9
|
+
celestial 的节气时刻是 UT1,民用输入是 UTC 或固定偏移;本模块只比较同一时标下的值。
|
|
10
|
+
|
|
11
|
+
- 立春比较:出生瞬间换算到 UT1 日数(`utc_to_ut1_jd`),与立春 UT1 时刻
|
|
12
|
+
(`lichun_jd`)直接比较,不直接比较钟面数值。
|
|
13
|
+
- 换算采用 celestial 的 modelled-UT1 定义:TT=UTC+(ΔAT+32.184s),UT1=TT-ΔT;
|
|
14
|
+
ΔAT 取 1972 年起闰秒表(最后一步 2017-01-01 后恒 37s,与 celestial 的 leap_second
|
|
15
|
+
表一致),ΔT 取 `delta_t` 的 DEFAULT 模型(ALGO5),在 TT 处估一次。该定义已与
|
|
16
|
+
`chart_snapshot` 返回的 `jd_ut1` 在 1972-2099 网格对拍(亚毫秒级一致)。
|
|
17
|
+
- 1972 年以前的 UTC 时刻由解析层拒绝;本模块只在解析层已取得 UTC 瞬间时被调用。
|
|
18
|
+
|
|
19
|
+
## 真太阳时
|
|
20
|
+
|
|
21
|
+
`apparent_solar_time` 以 UTC 民用时刻与东经经度换算当地真太阳钟;结果仍为 UTC 标
|
|
22
|
+
民用时刻(保留 UT1-UTC 的亚秒差距,取时辰时通常不影响)。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from datetime import UTC, date, datetime, timedelta
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LunarAlgorithm(StrEnum):
|
|
33
|
+
"""农历算法的具名选项;算法与版本随解析结果保存。"""
|
|
34
|
+
|
|
35
|
+
ALGO1 = "HKO 表"
|
|
36
|
+
ALGO2 = "天文计算"
|
|
37
|
+
ALGO3 = "混合"
|
|
38
|
+
HKO表 = ALGO1
|
|
39
|
+
天文计算 = ALGO2
|
|
40
|
+
混合 = ALGO3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ALGORITHM_RANGE: Final[dict[LunarAlgorithm, tuple[int, int]]] = {
|
|
44
|
+
LunarAlgorithm.ALGO1: (1901, 2099),
|
|
45
|
+
LunarAlgorithm.ALGO2: (410, 2500),
|
|
46
|
+
LunarAlgorithm.ALGO3: (1600, 2199),
|
|
47
|
+
}
|
|
48
|
+
"""三种算法的农历年范围;与 celestial 的 supported_lunar_year_range 一致。"""
|
|
49
|
+
|
|
50
|
+
_DELTA_T_MODEL = "DEFAULT(ALGO5)"
|
|
51
|
+
"""立春比较固定使用的 ΔT 模型;与 celestial 的 jieqi_moment UT1 换算一致。"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def celestial_version() -> str:
|
|
55
|
+
"""已安装 celestial-calendar 的版本字符串。"""
|
|
56
|
+
import celestial_calendar as celestial
|
|
57
|
+
|
|
58
|
+
return celestial.__version__
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def supported_lunar_year_range(algorithm: LunarAlgorithm) -> tuple[int, int]:
|
|
62
|
+
"""所选农历算法的支持范围(农历年,含端点)。"""
|
|
63
|
+
if not isinstance(algorithm, LunarAlgorithm):
|
|
64
|
+
raise TypeError("农历算法须为 LunarAlgorithm 成员")
|
|
65
|
+
return _ALGORITHM_RANGE[algorithm]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_algorithm(algorithm: LunarAlgorithm) -> None:
|
|
69
|
+
if not isinstance(algorithm, LunarAlgorithm):
|
|
70
|
+
raise TypeError("农历算法须为 LunarAlgorithm 成员")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_day(day: date) -> None:
|
|
74
|
+
if type(day) is not date:
|
|
75
|
+
raise TypeError("公历日期须为 date,不接受 datetime")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _native_algorithm(algorithm: LunarAlgorithm, celestial: Any) -> Any:
|
|
79
|
+
"""把本仓算法枚举映射到 celestial 的 LunarAlgorithm 成员。"""
|
|
80
|
+
return {
|
|
81
|
+
LunarAlgorithm.ALGO1: celestial.LunarAlgorithm.ALGO1,
|
|
82
|
+
LunarAlgorithm.ALGO2: celestial.LunarAlgorithm.ALGO2,
|
|
83
|
+
LunarAlgorithm.ALGO3: celestial.LunarAlgorithm.ALGO3,
|
|
84
|
+
}[algorithm]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gregorian_to_lunar(algorithm: LunarAlgorithm, day: date) -> tuple[int, int, int, bool]:
|
|
88
|
+
"""公历日期标签换农历年月日与闰月标志。
|
|
89
|
+
|
|
90
|
+
换算以传入的当地民用日标签为基准:民用路径传 `BirthRecord.birth_date`,真太阳时
|
|
91
|
+
路径由解析层传真太阳钟的民用日(见 resolver)。超出算法支持范围或日期不可表示时
|
|
92
|
+
抛ValueError;年历查询或原生转换的内部故障原样传播。
|
|
93
|
+
"""
|
|
94
|
+
_check_algorithm(algorithm)
|
|
95
|
+
_check_day(day)
|
|
96
|
+
first_year, last_year = supported_lunar_year_range(algorithm)
|
|
97
|
+
first_day, _, _ = lunar_year_info(algorithm, first_year)
|
|
98
|
+
last_first_day, _, last_lengths = lunar_year_info(algorithm, last_year)
|
|
99
|
+
end = last_first_day + timedelta(days=sum(last_lengths))
|
|
100
|
+
if not first_day <= day < end:
|
|
101
|
+
raise ValueError(f"公历日期{day}超出{algorithm.value}支持范围")
|
|
102
|
+
import celestial_calendar as celestial
|
|
103
|
+
|
|
104
|
+
lunar = celestial.gregorian_to_lunar(_native_algorithm(algorithm, celestial), day)
|
|
105
|
+
return lunar.year, lunar.month, lunar.day, lunar.is_leap
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def lunar_to_gregorian(
|
|
109
|
+
algorithm: LunarAlgorithm, year: int, month: int, day: int, is_leap: bool
|
|
110
|
+
) -> date:
|
|
111
|
+
"""农历年月日与闰月标志换公历标签;组合不存在时抛ValueError,内部故障原样传播。"""
|
|
112
|
+
validate_lunar_date(algorithm, year, month, day, is_leap)
|
|
113
|
+
import celestial_calendar as celestial
|
|
114
|
+
|
|
115
|
+
lunar = celestial.LunarDate(year, month, day, is_leap)
|
|
116
|
+
return celestial.lunar_to_gregorian(_native_algorithm(algorithm, celestial), lunar).to_date()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lunar_year_info(
|
|
120
|
+
algorithm: LunarAlgorithm, year: int
|
|
121
|
+
) -> tuple[date, int | None, tuple[int, ...]]:
|
|
122
|
+
"""农历年的首日、闰月与逐月长(闰月直接排在同名月之后)。"""
|
|
123
|
+
_check_algorithm(algorithm)
|
|
124
|
+
import celestial_calendar as celestial
|
|
125
|
+
|
|
126
|
+
info = celestial.lunar_year_info(_native_algorithm(algorithm, celestial), year)
|
|
127
|
+
return info.first_day.to_date(), info.leap_month, info.month_lengths
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_lunar_date(
|
|
131
|
+
algorithm: LunarAlgorithm, year: int, month: int, day: int, is_leap: bool
|
|
132
|
+
) -> None:
|
|
133
|
+
"""核对农历年月日与闰月组合存在于所选算法;不存在或超范围显式失败。"""
|
|
134
|
+
_check_algorithm(algorithm)
|
|
135
|
+
for value, label in ((year, "农历年"), (month, "农历月"), (day, "农历日")):
|
|
136
|
+
if type(value) is not int:
|
|
137
|
+
raise TypeError(f"{label}须为整数,不接受 bool")
|
|
138
|
+
if type(is_leap) is not bool:
|
|
139
|
+
raise TypeError("闰月标志须为 bool")
|
|
140
|
+
if year < _ALGORITHM_RANGE[algorithm][0] or year > _ALGORITHM_RANGE[algorithm][1]:
|
|
141
|
+
raise ValueError(f"农历年{year}超出{LunarAlgorithm(algorithm).value}支持范围")
|
|
142
|
+
if not 1 <= month <= 12:
|
|
143
|
+
raise ValueError(f"农历月须在1至12之间:{month}")
|
|
144
|
+
if day < 1:
|
|
145
|
+
raise ValueError(f"农历{year}年{month}月没有第{day}日")
|
|
146
|
+
_, leap_month, month_lengths = lunar_year_info(algorithm, year)
|
|
147
|
+
if is_leap and month != leap_month:
|
|
148
|
+
raise ValueError(f"农历{year}年不存在闰{month}月")
|
|
149
|
+
# 月长元组把闰月插在同名月之后:闰月与其后的平月下标都要后移一位。
|
|
150
|
+
effective_month = month + (
|
|
151
|
+
1 if leap_month is not None and (month > leap_month or (is_leap and month == leap_month)) else 0
|
|
152
|
+
)
|
|
153
|
+
if effective_month > len(month_lengths):
|
|
154
|
+
raise ValueError(f"农历{year}年不存在第{month}月")
|
|
155
|
+
if day > month_lengths[effective_month - 1]:
|
|
156
|
+
raise ValueError(f"农历{year}年{month}月没有第{day}日")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
#: 闰秒表(TAI-UTC,秒),1972-01-01 起;2017-01-01 后恒为 37 秒。
|
|
160
|
+
#: 与 celestial-calendar 的 leap_second 表一致(IERS 闰秒数据,公开资料)。
|
|
161
|
+
_LEAP_SECOND_TABLE: Final[tuple[tuple[date, int], ...]] = (
|
|
162
|
+
(date(1972, 1, 1), 10),
|
|
163
|
+
(date(1972, 7, 1), 11),
|
|
164
|
+
(date(1973, 1, 1), 12),
|
|
165
|
+
(date(1974, 1, 1), 13),
|
|
166
|
+
(date(1975, 1, 1), 14),
|
|
167
|
+
(date(1976, 1, 1), 15),
|
|
168
|
+
(date(1977, 1, 1), 16),
|
|
169
|
+
(date(1978, 1, 1), 17),
|
|
170
|
+
(date(1979, 1, 1), 18),
|
|
171
|
+
(date(1980, 1, 1), 19),
|
|
172
|
+
(date(1981, 7, 1), 20),
|
|
173
|
+
(date(1982, 7, 1), 21),
|
|
174
|
+
(date(1983, 7, 1), 22),
|
|
175
|
+
(date(1985, 7, 1), 23),
|
|
176
|
+
(date(1988, 1, 1), 24),
|
|
177
|
+
(date(1990, 1, 1), 25),
|
|
178
|
+
(date(1991, 1, 1), 26),
|
|
179
|
+
(date(1992, 7, 1), 27),
|
|
180
|
+
(date(1993, 7, 1), 28),
|
|
181
|
+
(date(1994, 7, 1), 29),
|
|
182
|
+
(date(1996, 1, 1), 30),
|
|
183
|
+
(date(1997, 7, 1), 31),
|
|
184
|
+
(date(1999, 1, 1), 32),
|
|
185
|
+
(date(2006, 1, 1), 33),
|
|
186
|
+
(date(2009, 1, 1), 34),
|
|
187
|
+
(date(2012, 7, 1), 35),
|
|
188
|
+
(date(2015, 7, 1), 36),
|
|
189
|
+
(date(2017, 1, 1), 37),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _delta_at(day: date) -> int:
|
|
194
|
+
"""UTC 民用日的闰秒数;2017-01-01 后恒为 37。"""
|
|
195
|
+
value = 37
|
|
196
|
+
for boundary, leap in _LEAP_SECOND_TABLE:
|
|
197
|
+
if day < boundary:
|
|
198
|
+
break
|
|
199
|
+
value = leap
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _civil_to_jd(year: int, month: int, day: int, fraction: float) -> float:
|
|
204
|
+
"""公历(无历标)转儒略日;fraction 为当日小数。"""
|
|
205
|
+
a = (14 - month) // 12
|
|
206
|
+
y = year + 4800 - a
|
|
207
|
+
m = month + 12 * a - 3
|
|
208
|
+
julian = day + (153 * m + 2) // 5 + 365 * y + y // 4 - y // 100 + y // 400 - 32045
|
|
209
|
+
return julian + fraction - 0.5
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _jd_to_civil(jd: float) -> tuple[int, int, int, float]:
|
|
213
|
+
"""儒略日转公历年月日与当日小数(与 _civil_to_jd 互逆)。"""
|
|
214
|
+
adjusted = jd + 0.5
|
|
215
|
+
z = int(adjusted)
|
|
216
|
+
fraction = adjusted - z
|
|
217
|
+
if z < 2299161:
|
|
218
|
+
a = float(z)
|
|
219
|
+
else:
|
|
220
|
+
alpha = (z - 1867216.25) // 36524.25
|
|
221
|
+
a = z + 1 + alpha - alpha // 4
|
|
222
|
+
b = a + 1524
|
|
223
|
+
c = (b - 122.1) // 365.25
|
|
224
|
+
d = int(365.25 * c)
|
|
225
|
+
e = (b - d) // 30.6001
|
|
226
|
+
day_float = b - d - int(30.6001 * e) + fraction
|
|
227
|
+
day = int(day_float)
|
|
228
|
+
month = int(e) - 1 if e < 14 else int(e) - 13
|
|
229
|
+
year = int(c) - 4716 if month > 2 else int(c) - 4715
|
|
230
|
+
return year, month, day, day_float - day
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _days_in_year(year: int) -> int:
|
|
234
|
+
return 366 if (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 else 365
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _decimal_year(jd: float) -> float:
|
|
238
|
+
"""儒略日对应的十进制公历年,供 delta_t 取 ΔT。"""
|
|
239
|
+
year, month, day, fraction = _jd_to_civil(jd)
|
|
240
|
+
day_of_year = int(_civil_to_jd(year, month, 1, 0) - _civil_to_jd(year, 1, 1, 0)) + day
|
|
241
|
+
return year + (day_of_year - 1 + fraction) / _days_in_year(year)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _check_utc(utc: datetime) -> None:
|
|
245
|
+
if type(utc) is not datetime:
|
|
246
|
+
raise TypeError("UTC 时刻须为 datetime")
|
|
247
|
+
if utc.tzinfo is None or utc.utcoffset() is None:
|
|
248
|
+
raise ValueError("UTC 时刻须带时区")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def utc_to_ut1_jd(utc: datetime) -> float:
|
|
252
|
+
"""把 UTC 瞬间按 modelled-UT1 定义换算到 UT1 日数。
|
|
253
|
+
|
|
254
|
+
TT=UTC+(ΔAT+32.184s),UT1=TT-ΔT;ΔT 在 TT 处估一次,固定 DEFAULT(ALGO5)。
|
|
255
|
+
该值与 celestial 的 chart_snapshot(jd_ut1) 在 1972-2099 网格上一致(亚毫秒级)。
|
|
256
|
+
"""
|
|
257
|
+
_check_utc(utc)
|
|
258
|
+
utc_utc = utc.astimezone(UTC)
|
|
259
|
+
if utc_utc < datetime(1972, 1, 1, tzinfo=UTC):
|
|
260
|
+
raise ValueError("UTC 早于1972-01-01的时刻须另定时标合同")
|
|
261
|
+
fraction = (
|
|
262
|
+
utc_utc.hour * 3600 + utc_utc.minute * 60 + utc_utc.second + utc_utc.microsecond / 1e6
|
|
263
|
+
) / 86400.0
|
|
264
|
+
jd_utc = _civil_to_jd(utc_utc.year, utc_utc.month, utc_utc.day, fraction)
|
|
265
|
+
tt = jd_utc + (_delta_at(utc_utc.date()) + 32.184) / 86400.0
|
|
266
|
+
import celestial_calendar as celestial
|
|
267
|
+
|
|
268
|
+
delta_t = celestial.delta_t(_decimal_year(tt), celestial.DeltaTModel.DEFAULT)
|
|
269
|
+
return tt - delta_t / 86400.0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def lichun_jd(year: int) -> float:
|
|
273
|
+
"""农历(干支)年 year 的立春 UT1 时刻(UT1 日数)。
|
|
274
|
+
|
|
275
|
+
jieqi_moment 返回的立春时刻为 UT1;返回日数可直接与 utc_to_ut1_jd 比较。
|
|
276
|
+
立春在某公历年内,出生瞬间 ≥ 该年立春即归该干支年。
|
|
277
|
+
"""
|
|
278
|
+
if type(year) is not int:
|
|
279
|
+
raise TypeError("立春查询年份须为整数")
|
|
280
|
+
import celestial_calendar as celestial
|
|
281
|
+
|
|
282
|
+
moment = celestial.jieqi_moment(year, celestial.Jieqi.LICHUN)
|
|
283
|
+
return celestial.ut1_to_jd(moment.moment_ut1)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def utc_civil(utc: datetime) -> tuple[int, int, int, float]:
|
|
287
|
+
"""UTC 瞬间转民用年月日与当日小数,供真太阳时换算。"""
|
|
288
|
+
_check_utc(utc)
|
|
289
|
+
utc_utc = utc.astimezone(UTC)
|
|
290
|
+
fraction = (
|
|
291
|
+
utc_utc.hour * 3600 + utc_utc.minute * 60 + utc_utc.second + utc_utc.microsecond / 1e6
|
|
292
|
+
) / 86400.0
|
|
293
|
+
return utc_utc.year, utc_utc.month, utc_utc.day, fraction
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def apparent_solar(utc: datetime, longitude_deg: float) -> tuple[int, int, int, float]:
|
|
297
|
+
"""当地真太阳钟的年月日与当日小数;经度为东经正。"""
|
|
298
|
+
_check_utc(utc)
|
|
299
|
+
import celestial_calendar as celestial
|
|
300
|
+
|
|
301
|
+
year, month, day, fraction = utc_civil(utc)
|
|
302
|
+
result = celestial.apparent_solar_time(
|
|
303
|
+
celestial.CivilDateTime(year, month, day, fraction), longitude_deg
|
|
304
|
+
)
|
|
305
|
+
return result.year, result.month, result.day, result.fraction
|