公众授权版:normal · 1000 次 · 有效期至 2026-12-31

副本编号 LY-20261007-AC63(水印指纹已嵌入)
含 README、.gitignore;排除 .DS_Store / __pycache__ / .pytest_cache
This commit is contained in:
2026-10-07 17:29:52 +08:00
commit d9da4301cd
772 changed files with 48534 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
"""软授权锁(防君子不防小人)。
总开关就是下面的 `_ENABLED` 一行变量:置 False 则完全放行。
本锁只是授权边界的可见表达,真正的约束在 LICENSE.md 的法律条款——
绕过、篡改、移除软锁或水印,即构成对授权的违反。
母版(技能开发目录)无 `data/.license.json`,本模块全部放行;
由 `tools/make_license_copy.py` 生成的定制副本带该状态文件,
按剩余次数与到期日限制使用,并以签名防止手工篡改状态文件。
退出码 4 = 授权不可用(次数用尽 / 已过期 / 状态文件被篡改)。
"""
from __future__ import annotations
import hashlib
import json
from datetime import date
from pathlib import Path
_ENABLED = True # 软锁总开关:False = 完全放行
_SALT = "lingyao-miaojie-ziping-eightchar"
_COMPANY = "四川灵爻妙解文化传播有限公司"
STATE_REL = Path("data") / ".license.json"
def _state_path(root: Path) -> Path:
return root / STATE_REL
def _sig(st: dict) -> str:
raw = "|".join([
str(st["copy_id"]), str(st["licensee"]), str(st["max_uses"]),
str(st["remaining_uses"]), str(st["expires_at"]), _SALT,
])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _load(root: Path) -> dict | None:
p = _state_path(root)
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
# 状态文件损坏按篡改变体处理:拒跑
return {"copy_id": "?", "licensee": "?", "remaining_uses": 0,
"max_uses": 0, "expires_at": None, "sig": ""}
def _deny(reason: str, st: dict) -> str:
return (f"授权不可用:{reason}。"
f"(副本编号 {st.get('copy_id', '?')},授权对象:{st.get('licensee', '?')})"
f"本技能为测试授权副本,禁止商业用途与个人传播;"
f"如需续期或正式授权,请联系{_COMPANY}。")
def check(root: Path) -> str | None:
"""返回 None 表示放行;返回字符串表示拒跑原因(由调用方打印到 stderr,退出码 4)。"""
if not _ENABLED:
return None
st = _load(root)
if st is None:
return None # 母版无状态文件,不锁
if st.get("sig") != _sig(st):
return _deny("授权状态校验失败(授权文件被改动过)", st)
exp = st.get("expires_at")
if exp and date.today() > date.fromisoformat(exp):
return _deny(f"授权已于 {exp} 到期", st)
if st.get("remaining_uses", 0) <= 0:
return _deny("本副本的使用次数已用尽", st)
return None
def consume(root: Path) -> None:
"""成功排盘一次后扣减一次。仅在 check 放行后调用。"""
if not _ENABLED:
return
st = _load(root)
if st is None:
return
st["remaining_uses"] = max(0, int(st.get("remaining_uses", 0)) - 1)
st["sig"] = _sig(st)
_state_path(root).write_text(
json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
def write_state(root: Path, *, copy_id: str, licensee: str,
max_uses: int, expires_at: str | None = None) -> dict:
"""生成授权状态文件(供 tools/make_license_copy.py 调用,签名算法单点在此)。"""
st = {
"copy_id": copy_id,
"licensee": licensee,
"issued_at": date.today().isoformat(),
"expires_at": expires_at,
"max_uses": max_uses,
"remaining_uses": max_uses,
}
st["sig"] = _sig(st)
p = _state_path(root)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
return st
View File
+23
View File
@@ -0,0 +1,23 @@
from datetime import datetime, timedelta
ZI_HOUR_START = 23 # 真太阳时 23:00 换日
def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
"""
子初换日:真太阳时 23:00–24:00 归入次日。
返回
(日历日 00:00 的 datetime, flag)
"""
is_late_zi = tst.hour == ZI_HOUR_START
day = tst.replace(hour=0, minute=0, second=0, microsecond=0)
if is_late_zi:
day += timedelta(days=1)
return day, {
"rule": "子初换日@真太阳时23:00",
"is_late_zi": is_late_zi,
"tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"),
}
+26
View File
@@ -0,0 +1,26 @@
import math
from datetime import datetime
def equation_of_time_minutes(dt_utc: datetime) -> float:
"""
均时差近似,精度约 ±15 秒(远优于时辰粒度所需)。
返回:真太阳时 = 平太阳时 + 返回值(分钟)。
"""
assert dt_utc.tzinfo is not None
# 一年中的天数序 + 当日小数
doy = dt_utc.timetuple().tm_yday
frac = (dt_utc.hour * 3600 + dt_utc.minute * 60 + dt_utc.second) / 86400
gamma = 2 * math.pi / 365.0 * (doy - 1 + (frac - 0.5))
# 经典近似式(Spencer / NOAA 系列),单位:分钟
eot = 229.18 * (
0.000075
+ 0.001868 * math.cos(gamma)
- 0.032077 * math.sin(gamma)
- 0.014615 * math.cos(2 * gamma)
- 0.040849 * math.sin(2 * gamma)
)
return eot
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timedelta
from .equation_of_time import equation_of_time_minutes
STANDARD_MERIDIAN = 120.0 # 北京时间基准经线
def to_true_solar_time(utc: datetime, lon: float) -> tuple[datetime, dict]:
"""
完整真太阳时归算。
链条:UTC → 120°E 平太阳时 → 经度修正 → 均时差 → 真太阳时
参数
utc : 带时区的 UTC datetime
lon : 出生地经度(东经为正)
返回
(真太阳时 datetime, audit)
"""
assert utc.tzinfo is not None, "必须传入带时区的 UTC 时间"
# 剥掉 tzinfo 是刻意的:后续全是 120°E 平太阳时域内的算术
base_naive = (utc + timedelta(hours=8)).replace(tzinfo=None)
# 经度修正:每偏离 1° 差 4 分钟
lon_corr = (lon - STANDARD_MERIDIAN) * 4.0
eot = equation_of_time_minutes(utc)
tst = base_naive + timedelta(minutes=lon_corr + eot)
return tst, {
"longitude": lon,
"lon_correction_min": round(lon_corr, 2),
"eot_minutes": round(eot, 2),
"total_correction_min": round(lon_corr + eot, 2),
"tst": tst,
}
+268
View File
@@ -0,0 +1,268 @@
"""
timezone_resolve.py — 把「当地钟表时间 + 地点」解析为 UTC。
诚实声明(近似性)
==================
1949 年以前中国的「民国五时区」在执行层面存在大量地方差异:各省、
各县乃至铁路、电报系统采用的标准时并不完全统一,本模块内嵌的
「年份 × 省份 → UTC 偏移」表是按 1919 年中央观象台五时区方案
(及后来的内政部修订)整理的**省级粒度近似**,与个别地方的实际
执行可能有数十分钟乃至一小时量级的出入。昆仑/新藏时区的分界
(82.5°E)在省级粒度无法表达,本表仅以少数西部城市的伪省份键
(「新疆西部」「西藏西部」)粗略体现。调用方应保留 audit 中的
source / tz_used,供人工复核边界情形。
"""
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
# ---------------------------------------------------------------------------
# 民国五时区(1912-01-01 ~ 1949-09-30):省份 → UTC 偏移(分钟)
# ---------------------------------------------------------------------------
# 昆仑时区 82.5°E UTC+5:30 新疆西部、西藏西部(省级粒度无法表达,见伪键)
# 新藏时区 90°E UTC+6 新疆东部、西藏东部
# 陇蜀时区 105°E UTC+7 陕西/甘肃/宁夏/四川/重庆/云南/贵州/青海大部
# 中原时区 120°E UTC+8 华东/华中/华北/华南大部
# 长白时区 127.5°E UTC+8:30 黑龙江/吉林
PROVINCE_OFFSET_MIN: dict[str, int] = {
# 新藏时区
"新疆": 360,
"西藏": 360,
# 昆仑时区(伪省份键,仅由城市表命中;省级粒度下新疆/西藏整体归入新藏时区)
"新疆西部": 330,
"西藏西部": 330,
# 陇蜀时区;西康(已撤销,今川西藏东)归此
"陕西": 420,
"甘肃": 420,
"宁夏": 420,
"四川": 420,
"重庆": 420,
"云南": 420,
"贵州": 420,
"青海": 420,
"西康": 420,
# 长白时区。辽宁历史上处于长白时区西缘(亦有资料归中原),
# 本表取 +510;与 +480 之差为半小时,属本表近似范围
"辽宁": 510,
"吉林": 510,
"黑龙江": 510,
# 中原标准时区。历史省份热河/察哈尔/绥远并入华北→中原;
# 内蒙古横跨甚广(西部实为陇蜀边缘),此处整体取中原,属近似
"北京": 480,
"天津": 480,
"河北": 480,
"山西": 480,
"内蒙古": 480,
"热河": 480,
"察哈尔": 480,
"绥远": 480,
"上海": 480,
"江苏": 480,
"浙江": 480,
"安徽": 480,
"福建": 480,
"江西": 480,
"山东": 480,
"河南": 480,
"湖北": 480,
"湖南": 480,
"广东": 480,
"广西": 480,
"海南": 480,
"台湾": 480,
"香港": 480,
"澳门": 480,
}
_ZONE_NAME = {
330: "昆仑时区",
360: "新藏时区",
420: "陇蜀时区",
480: "中原标准时区",
510: "长白时区",
}
_PSEUDO_PROVINCES = frozenset({"新疆西部", "西藏西部"})
# 城市名 → 省份(简表:省会及主要地级市;Location.name 常为城市名)
_CITY_TABLE: dict[str, tuple[str, ...]] = {
"河北": ("石家庄", "唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口",
"承德", "沧州", "廊坊", "衡水"),
"山西": ("太原", "大同", "阳泉", "长治", "晋城", "朔州", "晋中",
"运城", "忻州", "临汾", "吕梁"),
"内蒙古": ("呼和浩特", "包头", "乌海", "赤峰", "通辽", "鄂尔多斯",
"呼伦贝尔", "巴彦淖尔", "乌兰察布", "锡林郭勒", "阿拉善", "兴安"),
"辽宁": ("沈阳", "大连", "鞍山", "抚顺", "本溪", "丹东", "锦州",
"营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛"),
"吉林": ("长春", "吉林", "四平", "辽源", "通化", "白山", "松原",
"白城", "延边"),
"黑龙江": ("哈尔滨", "齐齐哈尔", "鸡西", "鹤岗", "双鸭山", "大庆",
"伊春", "佳木斯", "七台河", "牡丹江", "黑河", "绥化", "大兴安岭"),
"江苏": ("南京", "无锡", "徐州", "常州", "苏州", "南通", "连云港",
"淮安", "盐城", "扬州", "镇江", "泰州", "宿迁"),
"浙江": ("杭州", "宁波", "温州", "嘉兴", "湖州", "绍兴", "金华",
"衢州", "舟山", "台州", "丽水"),
"安徽": ("合肥", "芜湖", "蚌埠", "淮南", "马鞍山", "淮北", "铜陵",
"安庆", "黄山", "滁州", "阜阳", "宿州", "六安", "亳州",
"池州", "宣城"),
"福建": ("福州", "厦门", "莆田", "三明", "泉州", "漳州", "南平",
"龙岩", "宁德"),
"江西": ("南昌", "景德镇", "萍乡", "九江", "新余", "鹰潭", "赣州",
"吉安", "宜春", "抚州", "上饶"),
"山东": ("济南", "青岛", "淄博", "枣庄", "东营", "烟台", "潍坊",
"济宁", "泰安", "威海", "日照", "临沂", "德州", "聊城",
"滨州", "菏泽", "莱芜"),
"河南": ("郑州", "开封", "洛阳", "平顶山", "安阳", "鹤壁", "新乡",
"焦作", "濮阳", "许昌", "漯河", "三门峡", "南阳", "商丘",
"信阳", "周口", "驻马店", "济源"),
"湖北": ("武汉", "黄石", "十堰", "宜昌", "襄阳", "鄂州", "荆门",
"孝感", "荆州", "黄冈", "咸宁", "随州", "恩施", "仙桃",
"潜江", "天门"),
"湖南": ("长沙", "株洲", "湘潭", "衡阳", "邵阳", "岳阳", "常德",
"张家界", "益阳", "郴州", "永州", "怀化", "娄底", "湘西"),
"广东": ("广州", "韶关", "深圳", "珠海", "汕头", "佛山", "江门",
"湛江", "茂名", "肇庆", "惠州", "梅州", "汕尾", "河源",
"阳江", "清远", "东莞", "中山", "潮州", "揭阳", "云浮"),
"广西": ("南宁", "柳州", "桂林", "梧州", "北海", "防城港", "钦州",
"贵港", "玉林", "百色", "贺州", "河池", "来宾", "崇左"),
"海南": ("海口", "三亚", "三沙", "儋州"),
"四川": ("成都", "自贡", "攀枝花", "泸州", "德阳", "绵阳", "广元",
"遂宁", "内江", "乐山", "南充", "眉山", "宜宾", "广安",
"达州", "雅安", "巴中", "资阳", "阿坝", "甘孜", "凉山"),
"贵州": ("贵阳", "六盘水", "遵义", "安顺", "毕节", "铜仁",
"黔西南", "黔东南", "黔南"),
"云南": ("昆明", "曲靖", "玉溪", "保山", "昭通", "丽江", "普洱",
"临沧", "楚雄", "红河", "文山", "西双版纳", "大理", "德宏",
"怒江", "迪庆"),
"西藏": ("拉萨", "日喀则", "昌都", "林芝", "山南", "那曲"),
"西藏西部": ("阿里",),
"陕西": ("西安", "铜川", "宝鸡", "咸阳", "渭南", "延安", "汉中",
"榆林", "安康", "商洛"),
"甘肃": ("兰州", "嘉峪关", "金昌", "白银", "天水", "武威", "张掖",
"平凉", "酒泉", "庆阳", "定西", "陇南", "临夏", "甘南", "敦煌"),
"青海": ("西宁", "海东", "海北", "黄南", "果洛", "玉树", "海西", "海南州"),
"宁夏": ("银川", "石嘴山", "吴忠", "固原", "中卫"),
"新疆": ("乌鲁木齐", "克拉玛依", "吐鲁番", "哈密", "昌吉", "博尔塔拉",
"巴音郭楞", "库尔勒", "伊犁", "塔城", "阿勒泰", "石河子",
"奎屯", "阿拉尔", "五家渠"),
# 昆仑时区(82.5°E)大致覆盖:喀什噶尔、和阗一带
"新疆西部": ("喀什", "和田", "克孜勒苏", "阿克苏", "阿图什",
"图木舒克", "莎车", "叶城"),
"台湾": ("台北", "高雄", "台中", "台南", "基隆", "新竹", "嘉义", "桃园"),
}
CITY_TO_PROVINCE: dict[str, str] = {
city: prov for prov, cities in _CITY_TABLE.items() for city in cities
}
_NAME_TO_PROVINCE: dict[str, str] = {
**{p: p for p in PROVINCE_OFFSET_MIN if p not in _PSEUDO_PROVINCES},
**CITY_TO_PROVINCE,
}
_PREFIX_KEYS = sorted(_NAME_TO_PROVINCE, key=len, reverse=True)
_ADMIN_SUFFIXES = (
"维吾尔自治区", "壮族自治区", "回族自治区", "特别行政区",
"自治区", "省", "市", "地区", "州", "盟", "县", "区",
)
# 1986–1991 全国夏令时期间未实行夏令时的省份(官方仍用 UTC+8 北京时间;
# tzdata 的 Asia/Urumqi 条目同样不含 DST 规则,可相互印证)
_DST_EXEMPT_PROVINCES = frozenset({"新疆"})
_HIST_START = datetime(1912, 1, 1)
_HIST_END = datetime(1949, 10, 1) # 自此起回退 zoneinfo(Asia/Shanghai,含 1986–1991 DST)
def _strip_admin_suffix(name: str) -> str:
for suffix in _ADMIN_SUFFIXES:
if name.endswith(suffix) and len(name) > len(suffix):
return name[: -len(suffix)]
return name
def _resolve_province(name: str) -> str | None:
"""从 Location.name 提取省份键;name 可能是省名或城市名,匹配不到返回 None。"""
key = (name or "").strip()
if not key:
return None
if key in _NAME_TO_PROVINCE:
return _NAME_TO_PROVINCE[key]
stripped = _strip_admin_suffix(key)
if stripped in _NAME_TO_PROVINCE:
return _NAME_TO_PROVINCE[stripped]
# 前缀匹配,如「甘肃省兰州市」「新疆乌鲁木齐」
for cand in _PREFIX_KEYS:
if key.startswith(cand):
return _NAME_TO_PROVINCE[cand]
return None
def historical_offset_minutes(dt_naive: datetime, province: str) -> int | None:
"""
若自维护表覆盖该 (年份, 省份),返回 UTC 偏移(分钟)。
否则返回 None,由调用方回退到 zoneinfo。
覆盖区间:1912-01-01 ~ 1949-09-30(民国五时区)。
1912 年之前返回 None(zoneinfo 给 LMT,符合历史实况);
1949-10-01 起返回 None(全国统一北京时间,zoneinfo 正确处理 1986–1991 DST)。
"""
if not province or dt_naive < _HIST_START or dt_naive >= _HIST_END:
return None
key = province.strip()
return PROVINCE_OFFSET_MIN.get(key) or PROVINCE_OFFSET_MIN.get(
_strip_admin_suffix(key)
)
def _fmt_offset(minutes: int) -> str:
sign = "+" if minutes >= 0 else "-"
m = abs(minutes)
return f"UTC{sign}{m // 60:02d}:{m % 60:02d}"
def to_utc(local_dt_naive: datetime, loc) -> tuple[datetime, dict]:
"""
返回 (UTC datetime, audit_info)。
audit_info 固定键:tz_used(str)、is_dst(bool)、dst_offset_min(int)、source(str)。
"""
province = _resolve_province(getattr(loc, "name", ""))
offset = historical_offset_minutes(local_dt_naive, province)
if offset is not None:
utc = (local_dt_naive - timedelta(minutes=offset)).replace(
tzinfo=timezone.utc
)
return utc, {
"tz_used": f"{_ZONE_NAME[offset]}({_fmt_offset(offset)})",
"is_dst": False,
"dst_offset_min": 0,
"source": "historical_table",
}
tz_name = getattr(loc, "tz", None) or "Asia/Shanghai"
tz = ZoneInfo(tz_name)
localized = local_dt_naive.replace(tzinfo=tz)
dst = localized.dst()
is_dst = bool(dst) and dst != timedelta(0)
dst_min = int(dst.total_seconds() // 60) if is_dst else 0
if is_dst and province in _DST_EXEMPT_PROVINCES:
# 退回标准时(UTC+8):直接按「标准偏移」换算,保持 naive 不变
std_offset = localized.utcoffset() - dst
utc = (local_dt_naive - std_offset).replace(tzinfo=timezone.utc)
return utc, {
"tz_used": tz_name,
"is_dst": False,
"dst_offset_min": 0,
"source": "zoneinfo",
}
utc = localized.astimezone(timezone.utc)
return utc, {
"tz_used": tz_name,
"is_dst": is_dst,
"dst_offset_min": dst_min,
"source": "zoneinfo",
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# 副本 LY-20261007-AC63 · 授权对象 normal
"""子平排盘 —— 唯一对外接口。
用法:
python cast_chart.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市"
输出:Markdown 到 stdout。
退出码:0 成功;2 输入不可用(应追问用户);3 库内部错误(报错,不猜);4 授权不可用(软锁,见 LICENSE.md)。
"""
import argparse
import sys
import traceback
from datetime import datetime
from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
# vendor 目录不存在时回退到环境中已安装的包(开发模式)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
sys.path.insert(0, str(_VENDOR))
def main() -> int:
ap = argparse.ArgumentParser(
prog="cast_chart.py",
description="子平真诠八字排盘:姓名/性别/出生时刻/出生地 → 结构化命盘 Markdown(只排盘,不断命)",
)
ap.add_argument("--name", required=True, help="姓名")
ap.add_argument("--gender", required=True, choices=["男", "女"],
help="性别:男 或 女")
ap.add_argument("--birth", required=True,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
ap.add_argument("--place", required=True,
help="出生地,精确到地级市一级,如「四川省成都市」")
a = ap.parse_args()
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
import _license_gate as _gate
_deny = _gate.check(Path(__file__).resolve().parents[1])
if _deny:
print(f"ERROR: {_deny}", file=sys.stderr)
return 4
try:
# 延迟导入:让 --help 不依赖下游模块就绪;导入失败按库内部错误(退出码 3)处理
from geo.cities import resolve_location, LocationError
from astro.timezone_resolve import to_utc
from astro.solar_time import to_true_solar_time
from astro.day_boundary import resolve_calendar_day
from chart.tyme_adapter import cast
from chart.luck import (
compute_luck, decade_pillars, flow_months, flow_years, is_forward,
)
from chart.model import build_chart_data
from render.markdown import render
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
try:
loc = resolve_location(a.place)
except LocationError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
# L0-b 出生时刻解析
try:
clock = datetime.strptime(a.birth.strip(), "%Y-%m-%d %H:%M")
except ValueError:
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
return 2
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
utc, tz_audit = to_utc(clock, loc)
# L0-d UTC → 真太阳时(经度修正 + 均时差)
tst, st_audit = to_true_solar_time(utc, loc.lon)
# L0-e 子初换日:真太阳时 23:00–24:00 归入次日
day, bd_audit = resolve_calendar_day(tst)
# 排盘时刻 = 换日后的日历日 + 真太阳时的时分秒
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
# L1-a 排盘(tyme4py 唯一接触面)
raw = cast(cast_dt, a.gender)
# L1-b 起运、大运、流年、流月
forward = is_forward(a.gender, raw["year"]["gan"])
luck = compute_luck(
birth_tst=tst,
prev_jie_time=raw["prev_jie"]["time"],
next_jie_time=raw["next_jie"]["time"],
forward=forward,
)
decade = decade_pillars(
raw["month"]["gan"], raw["month"]["zhi"], forward,
luck["start_age_years"], cast_dt.year,
)
this_year = datetime.now().year
years = flow_years(this_year, 10)
months = flow_months(this_year)
# L1-c 组装 ChartData
chart = build_chart_data(
name=a.name, gender=a.gender, place=a.place, clock=a.birth,
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
raw=raw, luck=luck, luck_pillars=decade,
flow_years=years, flow_months=months,
)
# L2 渲染:stdout 只出 Markdown
print(render(chart))
_gate.consume(Path(__file__).resolve().parents[1])
return 0
except Exception:
print("ERROR: 排盘内部错误。请把以下信息原样报告给用户,"
"不要自行猜测命盘。", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())
View File
+132
View File
@@ -0,0 +1,132 @@
"""起运与大运(文档 4.8 节)。
规则(真诠体系):
- 阳男阴女顺排,阴男阳女逆排
- 起运:顺排取出生时刻到下一节、逆排取到上一节的时间差
- 折算:3 天 = 1 年(1 年 = 12 个月 = 360 天)
- 大运每步 10 年,从月柱干支起顺/逆推
纯 Python 历法推算,不 import tyme4py。
"""
from datetime import datetime
from .model import LuckPillar
GAN = "甲乙丙丁戊己庚辛壬癸"
ZHI = "子丑寅卯辰巳午未申酉戌亥"
YANG_GAN = frozenset("甲丙戊庚壬")
# 年上起月法(五虎遁):年干 → 寅月天干
# 甲己之年丙作首,乙庚之年戊为头,丙辛之年寻庚起,丁壬壬寅顺水流,戊癸甲寅
_MONTH_HEAD_GAN = {
"甲": "丙", "己": "丙",
"乙": "戊", "庚": "戊",
"丙": "庚", "辛": "庚",
"丁": "壬", "壬": "壬",
"戊": "甲", "癸": "甲",
}
# 节气月:月支从寅起(立春为寅月),(月支, 起始节气)
_SOLAR_MONTHS = (
("寅", "立春"), ("卯", "惊蛰"), ("辰", "清明"), ("巳", "立夏"),
("午", "芒种"), ("未", "小暑"), ("申", "立秋"), ("酉", "白露"),
("戌", "寒露"), ("亥", "立冬"), ("子", "大雪"), ("丑", "小寒"),
)
SECONDS_PER_DAY = 86400
DAYS_PER_LUCK_YEAR = 3.0 # 3 天折 1 年
def ganzhi_of_year(y: int) -> str:
"""公元 4 年为甲子,(y-4)%60 定位。"""
return GAN[(y - 4) % 10] + ZHI[(y - 4) % 12]
def ganzhi_cycle_next(gan: str, zhi: str, n: int) -> tuple[str, str]:
"""干支同步推进 n 步(n 可为负)。"""
return GAN[(GAN.index(gan) + n) % 10], ZHI[(ZHI.index(zhi) + n) % 12]
def is_forward(gender: str, year_gan: str) -> bool:
"""阳年干(甲丙戊庚壬)+ 男 → 顺;阴年干 + 女 → 顺。"""
return (year_gan in YANG_GAN) == (gender == "男")
def compute_luck(
birth_tst: datetime,
prev_jie_time: datetime,
next_jie_time: datetime,
forward: bool,
) -> dict:
"""起运:顺排取下一节减出生,逆排取出生减上一节;3 天折 1 年。
返回 {"is_forward", "start_age_years", "start_age_text"}。
start_age_years 保留 4 位小数;起运年龄精度要到「天」级,否则起运年份会差一年。
"""
if forward:
delta = next_jie_time - birth_tst
else:
delta = birth_tst - prev_jie_time
start_age_years = delta.total_seconds() / SECONDS_PER_DAY / DAYS_PER_LUCK_YEAR
return {
"is_forward": forward,
"start_age_years": round(start_age_years, 4),
"start_age_text": _age_to_text(start_age_years),
}
def _age_to_text(years: float) -> str:
"""年数 → 「X 岁 Y 个月 Z 天」。
按 1 年 = 12 个月 = 360 天折算(0.5 年 = 6 个月),四舍五入到天。
"""
total_days = round(years * 360)
y = total_days // 360
m = (total_days % 360) // 30
d = total_days % 30
return f"{y} 岁 {m} 个月 {d} 天"
def decade_pillars(
month_gan: str,
month_zhi: str,
forward: bool,
start_age_years: float,
birth_year: int,
n: int = 8,
) -> list[LuckPillar]:
"""从月柱起顺/逆推 n 步大运(第 1 步为月柱 ±1)。"""
step = 1 if forward else -1
pillars = []
for index in range(n):
gan, zhi = ganzhi_cycle_next(month_gan, month_zhi, step * (index + 1))
start_age = start_age_years + 10 * index
pillars.append(LuckPillar(
index=index + 1,
gan=gan,
zhi=zhi,
start_age=round(start_age, 4),
start_year=birth_year + round(start_age),
))
return pillars
def flow_years(current_year: int, n: int = 10) -> list[tuple[int, str]]:
"""从今年起 n 个流年干支。"""
return [(y, ganzhi_of_year(y)) for y in range(current_year, current_year + n)]
def flow_months(year: int) -> list[tuple[str, str]]:
"""该年 12 个节气月,返回 (「X月(节气起)」, 干支)。
月支从寅起(立春为寅月);月干按年上起月法(五虎遁)。
"""
year_gan = GAN[(year - 4) % 10]
head = GAN.index(_MONTH_HEAD_GAN[year_gan])
return [
(f"{zhi}月({jie}起)", GAN[(head + i) % 10] + zhi)
for i, (zhi, jie) in enumerate(_SOLAR_MONTHS)
]
+151
View File
@@ -0,0 +1,151 @@
"""L1→L2 数据契约(文档 4.7 节)。
改动本文件等于改契约版本(schema_version)。
本模块只做数据定义与组装,不含任何命理判断(P1)。
"""
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Pillar:
gan: str
zhi: str
gan_ten_god: str | None = None # 该柱天干相对日干的十神
hidden_stems: list[str] = field(default_factory=list) # 藏干
hidden_ten_gods: list[str] = field(default_factory=list)
na_yin: str | None = None
@dataclass
class LuckPillar:
index: int
gan: str
zhi: str
start_age: float
start_year: int
@dataclass
class ChartData:
# —— 输入 ——
name: str
gender: str
birthplace: str
birth_clock: str # 用户原始输入
# —— 时间归算审计(P3)——
tz_used: str
is_dst: bool
lon_correction_min: float
eot_minutes: float
true_solar_time: str
is_late_zi: bool
# —— 四柱 ——
year: Pillar
month: Pillar
day: Pillar
hour: Pillar
# —— 派生 ——
day_master: str # 日干
month_ling: str # 月令地支(取格之锚)
solar_term_before: str # 出生前一个节气
solar_term_after: str # 出生后一个节气
luck_pillars: list[LuckPillar] = field(default_factory=list)
# —— 派生(渲染层补充字段)——
luck_is_forward: bool = False # 大运顺排/逆排
start_age_text: str = "" # 起运年龄,如「7 岁 7 个月 19 天」
terrain: dict[str, str] = field(default_factory=dict) # 四柱键 → 十二长生
void: dict[str, str] = field(default_factory=dict) # {"day": .., "hour": ..}
shen_sha: list[str] = field(default_factory=list) # 神煞
flow_years: list[tuple[int, str]] = field(default_factory=list) # 流年 (年份, 干支)
flow_months: list[tuple[str, str]] = field(default_factory=list) # 流月 (标签, 干支)
tai_yuan: str = "" # 胎元
ming_gong: str = "" # 命宫
# —— 溯源 ——
library_version: str = "" # tyme4py 版本,写入输出
schema_version: str = "1.0"
def _fmt_dt(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d %H:%M")
def _fmt_term(term: dict) -> str:
"""{"name", "time"} → 「节气名 YYYY-MM-DD HH:MM」"""
return f"{term['name']} {_fmt_dt(term['time'])}"
def _build_pillar(key: str, raw: dict) -> Pillar:
gz = raw[key]
hidden_pairs = raw.get("hidden", {}).get(key, [])
return Pillar(
gan=gz["gan"],
zhi=gz["zhi"],
gan_ten_god=raw.get("ten_god", {}).get(key),
hidden_stems=[pair[0] for pair in hidden_pairs],
hidden_ten_gods=[pair[1] for pair in hidden_pairs],
na_yin=raw.get("na_yin", {}).get(key),
)
def build_chart_data(
name: str,
gender: str,
place: str,
clock: str,
loc,
tz_audit: dict,
st_audit: dict,
bd_audit: dict,
raw: dict,
luck: dict,
luck_pillars: list[LuckPillar],
flow_years: list[tuple[int, str]],
flow_months: list[tuple[str, str]],
) -> ChartData:
"""把 tyme_adapter 的原始 dict、各层 audit 与 luck 结果组装成 ChartData。
纯组装,零命理判断。
"""
return ChartData(
# 输入
name=name,
gender=gender,
birthplace=place,
birth_clock=clock,
# 时间归算审计
tz_used=tz_audit["tz_used"],
is_dst=tz_audit["is_dst"],
lon_correction_min=st_audit["lon_correction_min"],
eot_minutes=st_audit["eot_minutes"],
true_solar_time=_fmt_dt(st_audit["tst"]),
is_late_zi=bd_audit["is_late_zi"],
# 四柱
year=_build_pillar("year", raw),
month=_build_pillar("month", raw),
day=_build_pillar("day", raw),
hour=_build_pillar("hour", raw),
# 派生
day_master=raw["day_master"],
month_ling=raw["month"]["zhi"],
solar_term_before=_fmt_term(raw["prev_jie"]),
solar_term_after=_fmt_term(raw["next_jie"]),
luck_pillars=luck_pillars,
# 派生(补充字段)
luck_is_forward=luck["is_forward"],
start_age_text=luck["start_age_text"],
terrain=raw.get("terrain", {}),
void=raw.get("void", {}),
shen_sha=raw.get("shen_sha", []),
flow_years=flow_years,
flow_months=flow_months,
tai_yuan=raw.get("tai_yuan", ""),
ming_gong=raw.get("ming_gong", ""),
# 溯源
library_version=raw.get("library_version", ""),
)
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""tyme4py 唯一接触面:输入真太阳时时刻,返回四柱及派生信息(纯计算,无命理判断)。"""
from datetime import datetime
import tyme4py
from tyme4py.solar import SolarTime, SolarTerm
LIBRARY_VERSION = tyme4py.__version__
_PILLARS = ("year", "month", "day", "hour")
def _solar_time_to_dt(t: SolarTime) -> datetime:
return datetime(t.get_year(), t.get_month(), t.get_day(),
t.get_hour(), t.get_minute(), t.get_second())
def _jie_bounds(cast_dt: datetime) -> tuple:
"""出生时刻前后最近的两个节令(is_jie()),以出生年为中心 ±1 年遍历。"""
prev_jie = next_jie = None
for y in (cast_dt.year - 1, cast_dt.year, cast_dt.year + 1):
for i in range(24):
term = SolarTerm.from_index(y, i)
if not term.is_jie():
continue
t = _solar_time_to_dt(term.get_julian_day().get_solar_time())
if t < cast_dt and (prev_jie is None or t > prev_jie[1]):
prev_jie = (term, t)
elif t > cast_dt and (next_jie is None or t < next_jie[1]):
next_jie = (term, t)
return prev_jie, next_jie
def _hidden(me, earth_branch) -> list:
items = []
for getter in ("get_hide_heaven_stem_main",
"get_hide_heaven_stem_middle",
"get_hide_heaven_stem_residual"):
hs = getattr(earth_branch, getter)()
if hs is not None:
items.append([hs.get_name(), me.get_ten_star(hs).get_name()])
return items
def _shen_sha(solar: SolarTime) -> list:
try:
return [g.get_name() for g in solar.get_lunar_hour().get_lunar_day().get_gods()]
except Exception:
return []
def cast(cast_dt: datetime, gender: str) -> dict:
"""cast_dt:已完成子初换日调整的真太阳时(naive,北京时间语义)。"""
solar = SolarTime.from_ymd_hms(cast_dt.year, cast_dt.month, cast_dt.day,
cast_dt.hour, cast_dt.minute, cast_dt.second)
ec = solar.get_lunar_hour().get_eight_char()
pillars = {k: getattr(ec, f"get_{k}")() for k in _PILLARS}
me = ec.get_day().get_heaven_stem()
prev_jie, next_jie = _jie_bounds(cast_dt)
return {
**{k: {"gan": p.get_heaven_stem().get_name(),
"zhi": p.get_earth_branch().get_name()} for k, p in pillars.items()},
"day_master": me.get_name(),
"ten_god": {k: me.get_ten_star(pillars[k].get_heaven_stem()).get_name()
for k in ("year", "month", "hour")},
"hidden": {k: _hidden(me, pillars[k].get_earth_branch()) for k in _PILLARS},
"terrain": {k: me.get_terrain(pillars[k].get_earth_branch()).get_name()
for k in _PILLARS},
"na_yin": {k: pillars[k].get_sound().get_name() for k in _PILLARS},
"void": {k: "".join(b.get_name() for b in pillars[k].get_extra_earth_branches())
for k in ("day", "hour")},
"shen_sha": _shen_sha(solar),
"prev_jie": {"name": prev_jie[0].get_name(), "time": prev_jie[1]},
"next_jie": {"name": next_jie[0].get_name(), "time": next_jie[1]},
"tai_yuan": ec.get_fetal_origin().get_name(),
"ming_gong": ec.get_own_sign().get_name(),
"library_version": LIBRARY_VERSION,
"raw": ec,
}
+5
View File
@@ -0,0 +1,5 @@
"""地理模块:出生地字符串 → 经纬度与时区。"""
from .cities import Location, LocationError, resolve_location
__all__ = ["Location", "LocationError", "resolve_location"]
+84
View File
@@ -0,0 +1,84 @@
from dataclasses import dataclass
from pathlib import Path
import json
@dataclass(frozen=True)
class Location:
name: str
lon: float # 东经为正
lat: float
tz: str # IANA,如 "Asia/Shanghai"
class LocationError(Exception):
"""出生地无法解析 —— 必须向上抛,不得降级。"""
_CACHE: dict[str, Location] | None = None
_ALIASES: dict[str, str] | None = None
# 省级行政区简称/全称前缀,用于「新疆乌鲁木齐市」这类输入的兜底剥离
_PROVINCE_NAMES = (
"北京", "天津", "上海", "重庆",
"河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江",
"江苏", "浙江", "安徽", "福建", "江西", "山东",
"河南", "湖北", "湖南", "广东", "广西", "海南",
"四川", "贵州", "云南", "西藏", "陕西", "甘肃",
"青海", "宁夏", "新疆", "香港", "澳门", "台湾",
)
# 行政通名,长的必须排在短的前面(replace 顺序敏感)
_ADMIN_WORDS = (
"壮族自治区", "回族自治区", "维吾尔自治区", "自治区",
"特别行政区", "省", "市",
)
def _load() -> dict[str, Location]:
global _CACHE, _ALIASES
if _CACHE is None:
raw = json.loads(
(Path(__file__).parents[2] / "data" / "cities.json")
.read_text(encoding="utf-8")
)
_ALIASES = dict(raw.get("_aliases", {}))
_CACHE = {
k: Location(k, v["lon"], v["lat"], v["tz"])
for k, v in raw.items()
if not k.startswith("_") # 跳过 _meta / _aliases
}
return _CACHE
def _alias(key: str) -> str | None:
# 别名表:乌市、蓉城、羊城……(放在 cities.json 的 _aliases 段)
_load()
assert _ALIASES is not None
return _ALIASES.get(key)
def _strip_admin(text: str) -> str:
"""去掉「省/市/自治区/壮族自治区/回族自治区/维吾尔自治区/特别行政区」等字样。"""
for word in _ADMIN_WORDS:
text = text.replace(word, "")
return text
def resolve_location(text: str) -> Location:
"""
容错匹配:全称 → 去后缀(市/省/县/区)→ 别名 → 去行政通名(含省名前缀)。
命中即返回;未命中抛 LocationError(由 Agent 转成追问)。
"""
table = _load()
key = text.strip()
candidates: list[str | None] = [key, key.rstrip("市省县区"), _alias(key)]
stripped = _strip_admin(key)
candidates += [stripped, _alias(stripped)]
for province in _PROVINCE_NAMES:
if stripped != province and stripped.startswith(province):
rest = stripped[len(province):]
candidates += [rest, _alias(rest)]
for candidate in candidates:
if candidate and candidate in table:
return table[candidate]
raise LocationError(
f"未能解析出生地:{text!r}。请用户补充到地级市一级,"
f"如「四川省成都市」「新疆乌鲁木齐市」。"
)
View File
+116
View File
@@ -0,0 +1,116 @@
# scripts/render/markdown.py
"""ChartData → 自包含 Markdown(输出格式契约 v1.0)。
纯文本、无代码块、无表格嵌套;审计信息放末尾;字段名用中文;
不包含任何命理判断(P1)。
"""
def render(chart) -> str:
L = []
L.append(f"# 命盘:{chart.name}")
L.append("")
L.append(f"- 性别:{chart.gender}")
L.append(f"- 出生地:{chart.birthplace}")
L.append(f"- 输入时间:{chart.birth_clock}")
L.append(f"- 真太阳时:{chart.true_solar_time}")
if chart.is_late_zi:
L.append(
"- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
"已按「子初换日」归入次日。另存「子正换日」一派,"
"日柱与时柱将不同,结论需谨慎。"
)
L.append("")
L.append("## 四柱")
L.append("")
for key, label, p in (("year", "年柱", chart.year),
("month", "月柱", chart.month),
("day", "日柱", chart.day),
("hour", "时柱", chart.hour)):
god = p.gan_ten_god or ("日主" if key == "day" else "")
line = f"- {label}:{p.gan}{p.zhi}"
if god:
line += f"({god})"
changsheng = chart.terrain.get(key)
if changsheng:
line += f" · {changsheng}"
L.append(line)
L.append("")
L.append("## 地支藏干")
L.append("")
for label, p in (("年支", chart.year), ("月支", chart.month),
("日支", chart.day), ("时支", chart.hour)):
if p.hidden_stems:
pairs = "、".join(f"{s}({g})"
for s, g in zip(p.hidden_stems, p.hidden_ten_gods))
L.append(f"- {label} {p.zhi}:{pairs}")
L.append("")
L.append("## 四柱空亡")
L.append("")
L.append(f"- 日柱空亡:{chart.void['day']};时柱空亡:{chart.void['hour']}")
L.append("")
L.append("## 纳音")
L.append("")
for label, p in (("年柱", chart.year), ("月柱", chart.month),
("日柱", chart.day), ("时柱", chart.hour)):
if p.na_yin:
L.append(f"- {label}:{p.na_yin}")
L.append("")
L.append("## 神煞")
L.append("")
if chart.shen_sha:
for s in chart.shen_sha:
L.append(f"- {s}")
else:
L.append("- (本造未取神煞)")
L.append("")
L.append("## 节令")
L.append("")
L.append(f"- 月令:{chart.month_ling}")
L.append(f"- 生于 {chart.solar_term_before} 之后、{chart.solar_term_after} 之前")
L.append(f"- 胎元:{chart.tai_yuan} 命宫:{chart.ming_gong}")
L.append("")
L.append("## 大运")
L.append("")
direction = "顺行" if chart.luck_is_forward else "逆行"
if chart.luck_pillars:
L.append(f"- 排法:{direction};起运:{chart.start_age_text}"
f"({chart.luck_pillars[0].start_age:.2f} 岁)")
else:
L.append(f"- 排法:{direction};起运:{chart.start_age_text}")
for lp in chart.luck_pillars:
L.append(f"- 第 {lp.index} 步:{lp.gan}{lp.zhi}"
f"({lp.start_age:.2f} 岁起,{lp.start_year} 年)")
L.append("")
L.append("## 流年")
L.append("")
for year, gz in chart.flow_years:
L.append(f"- {year} {gz}")
L.append("")
L.append("## 流月")
L.append("")
for label, gz in chart.flow_months:
L.append(f"- {label}:{gz}")
L.append("")
L.append("---")
L.append("## 排盘审计")
L.append("")
L.append(f"- 时区:{chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})")
L.append(f"- 经度修正:{chart.lon_correction_min:+.2f} 分 / "
f"均时差:{chart.eot_minutes:+.2f} 分 / "
f"合计修正:{chart.lon_correction_min + chart.eot_minutes:+.2f} 分")
L.append("- 日界规则:子初换日(真太阳时 23:00)")
L.append(f"- 历法库:tyme4py {chart.library_version}")
L.append(f"- 输出 schema:{chart.schema_version}")
return "\n".join(L)