ziwei-chart 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.
@@ -0,0 +1,103 @@
1
+ """ziwei-chart: deterministic Zi Wei Dou Shu natal chart casting.
2
+
3
+ >>> from datetime import datetime
4
+ >>> from zoneinfo import ZoneInfo
5
+ >>> from ziwei_chart import BirthInput, cast_chart
6
+ >>> chart = cast_chart(BirthInput(
7
+ ... datetime=datetime(2004, 4, 25, 9, 20, tzinfo=ZoneInfo("Asia/Shanghai")),
8
+ ... gender="女"))
9
+ >>> chart.to_dict()["schema"]
10
+ 'ziwei_chart.v1'
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime
16
+ from typing import Literal
17
+
18
+ from .calendar import CalendarRules, resolve_calendar
19
+ from .models import Chart, HoroscopeChart
20
+ from .natal import build_natal_chart
21
+ from .rules import RuleSet
22
+
23
+ Gender = Literal["男", "女"]
24
+
25
+
26
+ @dataclass
27
+ class BirthInput:
28
+ """Birth data for casting.
29
+
30
+ - ``datetime``: aware civil birth datetime with an IANA ZoneInfo.
31
+ - ``gender``: "男" or "女".
32
+ - ``longitude``/``latitude``: optional; when both are given the civil
33
+ time is converted to true solar time (requires the ``[solar]`` extra).
34
+ Place names are resolved by the caller.
35
+ - ``bazi``/``lunar_date_str``: optional precomputed calendar. When both
36
+ are provided they are used as-is and ``datetime`` may be None — this is
37
+ the path used by applications that keep their own calendar service.
38
+ """
39
+
40
+ datetime: datetime | None = None
41
+ gender: Gender = "男"
42
+ longitude: float | None = None
43
+ latitude: float | None = None
44
+ bazi: str | None = None
45
+ lunar_date_str: str | None = None
46
+ calendar_rules: CalendarRules = field(default_factory=CalendarRules)
47
+
48
+ def __post_init__(self):
49
+ if self.gender not in ("男", "女"):
50
+ raise ValueError("紫微斗数暂不支持非二元性别")
51
+ has_precomputed = self.bazi is not None or self.lunar_date_str is not None
52
+ if has_precomputed and not (self.bazi and self.lunar_date_str):
53
+ raise ValueError("bazi and lunar_date_str must be provided together")
54
+ if not has_precomputed and self.datetime is None:
55
+ raise ValueError("either datetime or a precomputed bazi/lunar_date_str is required")
56
+
57
+
58
+ def cast_chart(birth_input: BirthInput, rules: RuleSet | None = None) -> Chart:
59
+ """Cast the natal chart (and unlock horoscope views) from a BirthInput."""
60
+ rules = rules or RuleSet.default()
61
+ if birth_input.bazi and birth_input.lunar_date_str:
62
+ chart = cast_chart_from_calendar(
63
+ bazi=birth_input.bazi, lunar_date_str=birth_input.lunar_date_str,
64
+ gender=birth_input.gender, rules=rules,
65
+ )
66
+ if birth_input.datetime is not None:
67
+ chart.input_receipt["datetime"] = birth_input.datetime.isoformat()
68
+ return chart
69
+
70
+ calendar_info = resolve_calendar(
71
+ birth_input.datetime, birth_input.longitude, birth_input.latitude,
72
+ birth_input.calendar_rules,
73
+ )
74
+ chart = cast_chart_from_calendar(
75
+ bazi=calendar_info.bazi, lunar_date_str=calendar_info.lunar_date_str,
76
+ gender=birth_input.gender, rules=rules,
77
+ )
78
+ chart.calendar = calendar_info
79
+ chart.input_receipt.update(calendar_info.as_receipt())
80
+ if birth_input.datetime is not None:
81
+ chart.input_receipt["datetime"] = birth_input.datetime.isoformat()
82
+ if birth_input.longitude is not None and birth_input.latitude is not None:
83
+ chart.input_receipt["coordinates"] = [birth_input.latitude, birth_input.longitude]
84
+ return chart
85
+
86
+
87
+ def cast_chart_from_calendar(bazi: str, lunar_date_str: str, gender: str,
88
+ rules: RuleSet | None = None) -> Chart:
89
+ """Cast the natal chart from a precomputed bazi + lunar_date_str pair."""
90
+ return build_natal_chart(bazi=bazi, lunar_date_str=lunar_date_str,
91
+ gender=gender, rules=rules)
92
+
93
+
94
+ __all__ = [
95
+ "BirthInput",
96
+ "CalendarRules",
97
+ "Chart",
98
+ "HoroscopeChart",
99
+ "RuleSet",
100
+ "cast_chart",
101
+ "cast_chart_from_calendar",
102
+ ]
103
+ __version__ = "0.1.0"
@@ -0,0 +1,160 @@
1
+ """Calendar resolution: true solar time, day/time-switch rules, bazi and
2
+ lunar date computation via lunar-python.
3
+
4
+ Default rules match the legacy suanqiu behaviour exactly:
5
+ - ``day_rule="zi-early"``: 23:00 (子初) starts the next day pillar.
6
+ - ``time_rule="next-day"``: the hour pillar for 23:00-24:00 is computed at
7
+ 00:30 of the following civil day.
8
+ - the lunar date used by the chart itself follows civil midnight, NOT the
9
+ day-pillar switch.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timedelta
16
+ from typing import Literal, NamedTuple
17
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
18
+
19
+ DayRule = Literal["midnight", "zi-early", "solar-midnight"]
20
+ TimeRule = Literal["midnight", "prev-day", "next-day"]
21
+
22
+
23
+ class LunarDateTime(NamedTuple):
24
+ year: int
25
+ month: int
26
+ day: int
27
+ hour: int
28
+ minute: int
29
+ second: int
30
+
31
+
32
+ _LUNAR_DATETIME_RE = re.compile(r"^\s*(\d{1,4})-(\d{1,2})-(\d{1,2}) (\d{1,2}):(\d{1,2}):(\d{1,2})\s*$")
33
+
34
+
35
+ def parse_lunar_datetime(text: str) -> LunarDateTime:
36
+ """解析 lunar_date_str。农历二月有廿九/三十,不能用 datetime.strptime。"""
37
+ match = _LUNAR_DATETIME_RE.match(text or "")
38
+ if not match:
39
+ raise ValueError(f"Invalid lunar date string: {text!r}")
40
+ value = LunarDateTime(*(int(part) for part in match.groups()))
41
+ if not (1 <= value.month <= 12 and 1 <= value.day <= 30
42
+ and value.hour < 24 and value.minute < 60 and value.second < 60):
43
+ raise ValueError(f"Invalid lunar date string: {text!r}")
44
+ return value
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class CalendarRules:
49
+ day_rule: DayRule = "zi-early"
50
+ time_rule: TimeRule = "next-day"
51
+
52
+
53
+ @dataclass
54
+ class CalendarInfo:
55
+ bazi: str
56
+ lunar_date_str: str
57
+ true_solar_time: datetime | None # from_calendar 路径时为 None
58
+ timezone: str
59
+ day_rule: DayRule
60
+ time_rule: TimeRule
61
+ civil_datetime: datetime | None = None
62
+
63
+ def as_receipt(self) -> dict:
64
+ return {
65
+ "bazi": self.bazi,
66
+ "lunar_date_str": self.lunar_date_str,
67
+ "true_solar_time": (
68
+ self.true_solar_time.strftime("%Y-%m-%d %H:%M:%S")
69
+ if self.true_solar_time else None
70
+ ),
71
+ "timezone": self.timezone,
72
+ "calendar_rules": {"day_rule": self.day_rule, "time_rule": self.time_rule},
73
+ }
74
+
75
+
76
+ def apply_day_switch(dt_ast: datetime, ast_hour_float: float, tz: str,
77
+ day_rule: DayRule = "zi-early") -> datetime:
78
+ """day_rule: "midnight" 子正换日 | "zi-early" 子初换日 | "solar-midnight" 真太阳午夜."""
79
+ dt_ast = dt_ast.astimezone(ZoneInfo(tz))
80
+ if day_rule == "zi-early":
81
+ if 23.0 <= ast_hour_float < 24.0:
82
+ return dt_ast + timedelta(days=1)
83
+ elif day_rule == "solar-midnight":
84
+ if ast_hour_float < 0.5:
85
+ return dt_ast + timedelta(days=1)
86
+ # "midnight" 不动
87
+ return dt_ast
88
+
89
+
90
+ def apply_time_switch(dt_for_day: datetime, ast_hour_float: float, tz: str,
91
+ time_rule: TimeRule = "next-day") -> datetime:
92
+ """time_rule: "midnight" 民用日界 | "next-day" 夜子归次日 | "prev-day" 早子归前日."""
93
+ dt_for_day = dt_for_day.astimezone(ZoneInfo(tz))
94
+ if time_rule == "next-day" and 23.0 <= ast_hour_float < 24.0:
95
+ # 不要简单 +1 小时(会跨到次次日);直接把时间“对齐”到当日 00:30
96
+ return dt_for_day.replace(hour=0, minute=30, second=0, microsecond=0)
97
+ if time_rule == "prev-day" and 0.0 <= ast_hour_float < 1.0:
98
+ prev = dt_for_day - timedelta(days=1)
99
+ return prev.replace(hour=23, minute=30, second=0, microsecond=0)
100
+ return dt_for_day
101
+
102
+
103
+ def _timezone_name(dt: datetime) -> str:
104
+ tz_name = getattr(dt.tzinfo, "key", None)
105
+ if not tz_name:
106
+ raise ValueError("BirthInput.datetime must carry an IANA ZoneInfo timezone")
107
+ return tz_name
108
+
109
+
110
+ def resolve_calendar(dt: datetime, longitude: float | None, latitude: float | None,
111
+ rules: CalendarRules) -> CalendarInfo:
112
+ """Compute bazi + chart lunar date from an aware civil datetime.
113
+
114
+ With ``longitude``/``latitude`` the civil time is first converted to true
115
+ solar time (requires the ``[solar]`` extra). Without them the datetime is
116
+ used as-is — no longitude or DST assumptions.
117
+ """
118
+ if dt.tzinfo is None or dt.utcoffset() is None:
119
+ raise ValueError("BirthInput.datetime must be timezone-aware")
120
+ tz = _timezone_name(dt)
121
+ if longitude is not None and latitude is not None:
122
+ from .solar_time import resolve_true_solar_time
123
+
124
+ dt_ast = resolve_true_solar_time(dt, lat=latitude, lon=longitude, tz=tz)
125
+ else:
126
+ dt_ast = dt.astimezone(ZoneInfo(tz))
127
+ h_ast = dt_ast.hour + dt_ast.minute / 60.0 + dt_ast.second / 3600.0
128
+
129
+ from lunar_python import Solar
130
+
131
+ dt_for_day = apply_day_switch(dt_ast, h_ast, tz, day_rule=rules.day_rule)
132
+ dt_for_bazi = apply_time_switch(dt_for_day, h_ast, tz, time_rule=rules.time_rule)
133
+ solar_obj = Solar.fromYmdHms(
134
+ dt_for_bazi.year, dt_for_bazi.month, dt_for_bazi.day,
135
+ dt_for_bazi.hour, dt_for_bazi.minute, dt_for_bazi.second,
136
+ )
137
+ ec = solar_obj.getLunar().getEightChar()
138
+
139
+ # 紫微使用实际排盘时刻的农历日期,不能继承八字日柱的子初换日。
140
+ chart_lunar = Solar.fromYmdHms(
141
+ dt_ast.year, dt_ast.month, dt_ast.day,
142
+ dt_ast.hour, dt_ast.minute, dt_ast.second,
143
+ ).getLunar()
144
+ lunar_year = chart_lunar.getYear()
145
+ lunar_month = abs(chart_lunar.getMonth()) # leap months come back negative
146
+ lunar_day = chart_lunar.getDay()
147
+ # 农历日期不能塞进公历 datetime:二月廿九/三十在公历二月不存在。只拼字符串。
148
+ lunar_date_str = (
149
+ f"{lunar_year:04d}-{lunar_month:02d}-{lunar_day:02d} "
150
+ f"{dt_ast.hour:02d}:{dt_ast.minute:02d}:{dt_ast.second:02d}"
151
+ )
152
+ return CalendarInfo(
153
+ bazi=f"{ec.getYear()}{ec.getMonth()}{ec.getDay()}{ec.getTime()}",
154
+ lunar_date_str=lunar_date_str,
155
+ true_solar_time=dt_ast,
156
+ timezone=tz,
157
+ day_rule=rules.day_rule,
158
+ time_rule=rules.time_rule,
159
+ civil_datetime=dt,
160
+ )
@@ -0,0 +1,62 @@
1
+ {
2
+ "甲子": "金",
3
+ "乙丑": "金",
4
+ "丙寅": "火",
5
+ "丁卯": "火",
6
+ "戊辰": "木",
7
+ "己巳": "木",
8
+ "庚午": "土",
9
+ "辛未": "土",
10
+ "壬申": "金",
11
+ "癸酉": "金",
12
+ "甲戌": "火",
13
+ "乙亥": "火",
14
+ "丙子": "水",
15
+ "丁丑": "水",
16
+ "戊寅": "土",
17
+ "己卯": "土",
18
+ "庚辰": "金",
19
+ "辛巳": "金",
20
+ "壬午": "木",
21
+ "癸未": "木",
22
+ "甲申": "水",
23
+ "乙酉": "水",
24
+ "丙戌": "土",
25
+ "丁亥": "土",
26
+ "戊子": "火",
27
+ "己丑": "火",
28
+ "庚寅": "木",
29
+ "辛卯": "木",
30
+ "壬辰": "水",
31
+ "癸巳": "水",
32
+ "甲午": "金",
33
+ "乙未": "金",
34
+ "丙申": "火",
35
+ "丁酉": "火",
36
+ "戊戌": "木",
37
+ "己亥": "木",
38
+ "庚子": "土",
39
+ "辛丑": "土",
40
+ "壬寅": "金",
41
+ "癸卯": "金",
42
+ "甲辰": "火",
43
+ "乙巳": "火",
44
+ "丙午": "水",
45
+ "丁未": "水",
46
+ "戊申": "土",
47
+ "己酉": "土",
48
+ "庚戌": "金",
49
+ "辛亥": "金",
50
+ "壬子": "木",
51
+ "癸丑": "木",
52
+ "甲寅": "水",
53
+ "乙卯": "水",
54
+ "丙辰": "土",
55
+ "丁巳": "土",
56
+ "戊午": "火",
57
+ "己未": "火",
58
+ "庚申": "木",
59
+ "辛酉": "木",
60
+ "壬戌": "水",
61
+ "癸亥": "水"
62
+ }
@@ -0,0 +1,171 @@
1
+ {
2
+ "紫微在子": {
3
+ "子": ["紫微"],
4
+ "丑": [],
5
+ "寅": ["破军"],
6
+ "卯": [],
7
+ "辰": ["天府", "廉贞"],
8
+ "巳": ["太阴"],
9
+ "午": ["贪狼"],
10
+ "未": ["天同", "巨门"],
11
+ "申": ["武曲", "天相"],
12
+ "酉": ["天梁", "太阳"],
13
+ "戌": ["七杀"],
14
+ "亥": ["天机"]
15
+ },
16
+ "紫微在丑": {
17
+ "子": ["天机"],
18
+ "丑": ["紫微", "破军"],
19
+ "寅": [],
20
+ "卯": ["天府"],
21
+ "辰": ["太阴"],
22
+ "巳": ["廉贞","贪狼"],
23
+ "午": ["巨门"],
24
+ "未": ["天相"],
25
+ "申": ["天同", "天梁"],
26
+ "酉": ["武曲", "七杀"],
27
+ "戌": ["太阳"],
28
+ "亥": []
29
+ },
30
+ "紫微在寅": {
31
+ "子": ["破军"],
32
+ "丑": ["天机"],
33
+ "寅": ["紫微", "天府"],
34
+ "卯": ["太阴"],
35
+ "辰": ["贪狼"],
36
+ "巳": ["巨门"],
37
+ "午": ["廉贞", "天相"],
38
+ "未": ["天梁"],
39
+ "申": ["七杀"],
40
+ "酉": ["天同"],
41
+ "戌": ["武曲"],
42
+ "亥": ["太阳"]
43
+ },
44
+ "紫微在卯": {
45
+ "子": ["太阳"],
46
+ "丑": ["天府"],
47
+ "寅": ["天机", "太阴"],
48
+ "卯": ["紫微", "贪狼"],
49
+ "辰": ["巨门"],
50
+ "巳": ["天相"],
51
+ "午": ["天梁"],
52
+ "未": ["廉贞", "七杀"],
53
+ "申": [],
54
+ "酉": [],
55
+ "戌": ["天同"],
56
+ "亥": ["武曲", "破军"]
57
+ },
58
+ "紫微在辰": {
59
+ "子": ["武曲", "天府"],
60
+ "丑": ["太阳", "太阴"],
61
+ "寅": ["贪狼"],
62
+ "卯": ["天机", "巨门"],
63
+ "辰": ["紫微", "天相"],
64
+ "巳": ["天梁"],
65
+ "午": ["七杀"],
66
+ "未": [],
67
+ "申": ["廉贞"],
68
+ "酉": [],
69
+ "戌": ["破军"],
70
+ "亥": ["天同"]
71
+ },
72
+ "紫微在巳": {
73
+ "子": ["天同", "太阴"],
74
+ "丑": ["武曲", "贪狼"],
75
+ "寅": ["巨门", "太阳"],
76
+ "卯": ["天相"],
77
+ "辰": ["天机", "天梁"],
78
+ "巳": ["紫微", "七杀"],
79
+ "午": [],
80
+ "未": [],
81
+ "申": [],
82
+ "酉": ["廉贞", "破军"],
83
+ "戌": [],
84
+ "亥": ["天府"]
85
+ },
86
+ "紫微在午": {
87
+ "子": ["贪狼"],
88
+ "丑": ["天同","巨门"],
89
+ "寅": ["武曲","天相"],
90
+ "卯": ["天梁","太阳"],
91
+ "辰": ["七杀"],
92
+ "巳": ["天机"],
93
+ "午": ["紫微"],
94
+ "未": [],
95
+ "申": ["破军"],
96
+ "酉": [],
97
+ "戌": ["天府","廉贞"],
98
+ "亥": ["太阴"]
99
+ },
100
+ "紫微在未": {
101
+ "子": ["巨门"],
102
+ "丑": ["天相"],
103
+ "寅": ["天同", "天梁"],
104
+ "卯": ["武曲", "七杀"],
105
+ "辰": ["太阳"],
106
+ "巳": [],
107
+ "午": ["天机"],
108
+ "未": ["紫微", "破军"],
109
+ "申": [],
110
+ "酉": ["天府"],
111
+ "戌": ["太阴"],
112
+ "亥": ["廉贞", "贪狼"]
113
+ },
114
+ "紫微在申": {
115
+ "子": ["廉贞", "天相"],
116
+ "丑": ["天梁"],
117
+ "寅": ["七杀"],
118
+ "卯": ["天同"],
119
+ "辰": ["武曲"],
120
+ "巳": ["太阳"],
121
+ "午": ["破军"],
122
+ "未": ["天机"],
123
+ "申": ["紫微", "天府"],
124
+ "酉": ["太阴"],
125
+ "戌": ["贪狼"],
126
+ "亥": ["巨门"]
127
+ },
128
+ "紫微在酉": {
129
+ "子": ["天梁"],
130
+ "丑": ["廉贞", "七杀"],
131
+ "寅": [],
132
+ "卯": [],
133
+ "辰": ["天同"],
134
+ "巳": ["武曲", "破军"],
135
+ "午": ["太阳"],
136
+ "未": ["天府"],
137
+ "申": ["天机", "太阴"],
138
+ "酉": ["紫微", "贪狼"],
139
+ "戌": ["巨门"],
140
+ "亥": ["天相"]
141
+ },
142
+ "紫微在戌": {
143
+ "子": ["七杀"],
144
+ "丑": [],
145
+ "寅": ["廉贞"],
146
+ "卯": [],
147
+ "辰": ["破军"],
148
+ "巳": ["天同"],
149
+ "午": ["武曲", "天府"],
150
+ "未": ["太阳", "太阴"],
151
+ "申": ["贪狼"],
152
+ "酉": ["天机", "巨门"],
153
+ "戌": ["紫微", "天相"],
154
+ "亥": ["天梁"]
155
+ },
156
+ "紫微在亥": {
157
+ "子": [],
158
+ "丑": [],
159
+ "寅": [],
160
+ "卯": ["廉贞", "破军"],
161
+ "辰": [],
162
+ "巳": ["天府"],
163
+ "午": ["天同", "太阴"],
164
+ "未": ["武曲", "贪狼"],
165
+ "申": ["巨门", "太阳"],
166
+ "酉": ["天相"],
167
+ "戌": ["天机", "天梁"],
168
+ "亥": ["紫微", "七杀"]
169
+ }
170
+ }
171
+
@@ -0,0 +1,62 @@
1
+ {
2
+ "甲": {
3
+ "廉贞": "禄",
4
+ "破军": "权",
5
+ "武曲": "科",
6
+ "太阳": "忌"
7
+ },
8
+ "乙": {
9
+ "天机": "禄",
10
+ "天梁": "权",
11
+ "紫微": "科",
12
+ "太阴": "忌"
13
+ },
14
+ "丙": {
15
+ "天同": "禄",
16
+ "天机": "权",
17
+ "文昌": "科",
18
+ "廉贞": "忌"
19
+ },
20
+ "丁": {
21
+ "太阴": "禄",
22
+ "天同": "权",
23
+ "天机": "科",
24
+ "巨门": "忌"
25
+ },
26
+ "戊": {
27
+ "贪狼": "禄",
28
+ "太阴": "权",
29
+ "右弼": "科",
30
+ "天机": "忌"
31
+ },
32
+ "己": {
33
+ "武曲": "禄",
34
+ "贪狼": "权",
35
+ "天梁": "科",
36
+ "文曲": "忌"
37
+ },
38
+ "庚": {
39
+ "太阳": "禄",
40
+ "武曲": "权",
41
+ "太阴": "科",
42
+ "天同": "忌"
43
+ },
44
+ "辛": {
45
+ "巨门": "禄",
46
+ "太阳": "权",
47
+ "文曲": "科",
48
+ "文昌": "忌"
49
+ },
50
+ "壬": {
51
+ "天梁": "禄",
52
+ "紫微": "权",
53
+ "左辅": "科",
54
+ "武曲": "忌"
55
+ },
56
+ "癸": {
57
+ "破军": "禄",
58
+ "巨门": "权",
59
+ "太阴": "科",
60
+ "贪狼": "忌"
61
+ }
62
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "甲": "丙",
3
+ "乙": "戊",
4
+ "丙": "庚",
5
+ "丁": "壬",
6
+ "戊": "甲",
7
+ "己": "丙",
8
+ "庚": "戊",
9
+ "辛": "庚",
10
+ "壬": "壬",
11
+ "癸": "甲"
12
+ }