公众授权版:normal · 1000 次 · 有效期至 2026-12-31
副本编号 LY-20261007-AC63(水印指纹已嵌入) 含 README、.gitignore;排除 .DS_Store / __pycache__ / .pytest_cache
This commit is contained in:
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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", ""),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""地理模块:出生地字符串 → 经纬度与时区。"""
|
||||
|
||||
from .cities import Location, LocationError, resolve_location
|
||||
|
||||
__all__ = ["Location", "LocationError", "resolve_location"]
|
||||
@@ -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"如「四川省成都市」「新疆乌鲁木齐市」。"
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user