- 晚子时(真太阳时 23:00–24:00,约 4% 的盘)修正:换日改由 tyme4py provider 原生实现(cast 新增 sect 参数,1=子初 / 2=子正),排盘层不再自行预滚日。3.6.1 及此前会滚两次,日柱多进一天、时柱随之错干——锚点 1973-08-23 23:32 男:旧 癸巳日壬子时 → 正 壬辰日庚子时。已排过的晚子时盘建议重排核对 - --day-boundary 子正 现真正生效(走 sect=2,晚子时日柱算当天),此前只改审计文案不改实际排盘 - data/cities.json 港澳 28 个条目由 Asia/Shanghai 改为 Asia/Hong_Kong(19)、Asia/Macau(9):大陆 1986–1991 行夏令时而港澳 1979 年后无,沿用上海区会把大陆夏令时错加到港澳命主身上 - 修副本环境 `pytest tests/` 收集期中断(test_sync_tool 模块级加载外层 tools/,skipif 挡不住导入),现按设计整模块 skip - .gitignore 新增 data/.license.json、data/.update_check.json,本机授权与更新缓存不再可能误入仓 - 测试 178 项全绿(副本内 sync 工具 4 项按设计 skip);manifest 1278 文件,ed25519 签名,公钥指纹 3089bc1cc9a453e5…
317 lines
14 KiB
Python
317 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""命主档案建档/更新 —— 排盘一次并写入 memory/archives/。
|
||
|
||
用法:
|
||
python memory_save.py --name 张三 --gender 男 \
|
||
--birth "1990-05-14 08:30" --place "四川省成都市"
|
||
|
||
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
|
||
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
|
||
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
|
||
输出:档案文件路径到 stdout。
|
||
|
||
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
|
||
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
|
||
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
|
||
|
||
换盘模式(--update-chart):姓名/性别/出生信息默认读档案 frontmatter,
|
||
--birth/--place 用于时辰/地点校正,--day-boundary 子正 切换日界规则(默认子初)。
|
||
换盘后「## 盘面」只保留新盘(工作盘唯一),旧盘信息由使用方记入「## 反馈与澄清」;
|
||
frontmatter 的 pillars/day_master/true_solar_time 同步为新盘,文件改为新主键名
|
||
(目标已存在且非同一文件时报错退出 2,不覆盖)。四柱有变动时 stderr 列出变动柱,
|
||
提示使用方把受影响推断条目(归纳、流年大事)标记【待复核】。
|
||
|
||
退出码: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))
|
||
|
||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||
|
||
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
|
||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||
if _VENDOR.is_dir():
|
||
sys.path.insert(0, str(_VENDOR))
|
||
|
||
import _memory_store as store
|
||
|
||
_PILLAR_LABELS = ("年柱", "月柱", "日柱", "时柱")
|
||
|
||
# render 层日界文案写定(scripts/render/ 不在此处改动),换盘子正时改写这两行
|
||
_AUDIT_ZICHU = "- 日界规则:子初换日(真太阳时 23:00)"
|
||
_AUDIT_ZIZHENG = "- 日界规则:子正换日(真太阳时 00:00)"
|
||
_WARN_ZICHU = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||
"已按「子初换日」归入次日。另存「子正换日」一派,"
|
||
"日柱与时柱将不同,结论需谨慎。")
|
||
_WARN_ZIZHENG = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||
"已按「子正换日」定盘(日界 0:00,不换日)。"
|
||
"另存「子初换日」一派,日柱与时柱将不同,结论需谨慎。")
|
||
|
||
|
||
def _cast_chart(name: str, gender: str, birth: str, place: str,
|
||
day_boundary: str = "子初"):
|
||
"""排盘管线(geo → astro → chart → render),与 cast_chart.py 同一调用序列。
|
||
|
||
返回 (命盘 Markdown, 四柱 list, 日主, 真太阳时 str);地点/时刻不可用抛
|
||
LocationError/ValueError(由调用方映射退出码 2)。
|
||
"""
|
||
from geo.cities import resolve_location
|
||
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 (
|
||
childhood_luck, compute_luck, decade_pillars, flow_months,
|
||
flow_years, is_forward, luck_label_for_year,
|
||
)
|
||
from chart.model import build_chart_data
|
||
from render.markdown import render
|
||
|
||
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
|
||
loc = resolve_location(place)
|
||
|
||
# L0-b 出生时刻解析
|
||
clock = datetime.strptime(birth.strip(), "%Y-%m-%d %H:%M")
|
||
|
||
# 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 日界审计:is_late_zi 警示与口径文案;换日由排盘层 provider
|
||
# 口径实现(sect 参数),此处不再对时刻预先滚日(避免双重换日)
|
||
_day, bd_audit = resolve_calendar_day(tst, rule=day_boundary)
|
||
|
||
cast_dt = tst
|
||
|
||
# L1-a 排盘(tyme4py 唯一接触面);子正口径走 sect=2(晚子时日柱算当天)
|
||
raw = cast(cast_dt, gender, sect=2 if day_boundary == "子正" else 1)
|
||
|
||
# L1-b 起运、大运、流年、流月、小运
|
||
forward = is_forward(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,
|
||
)
|
||
this_year = datetime.now().year
|
||
n_pillars = max(
|
||
8,
|
||
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
|
||
)
|
||
decade = decade_pillars(
|
||
raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||
luck["start_age_years"], cast_dt.year, n=n_pillars,
|
||
)
|
||
years = [
|
||
(y, gz, luck_label_for_year(y, decade))
|
||
for y, gz in flow_years(cast_dt.year, this_year, 10)
|
||
]
|
||
months = flow_months(this_year)
|
||
childhood = childhood_luck(
|
||
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
|
||
luck["start_age_years"], cast_dt.year,
|
||
)
|
||
|
||
# L1-c 组装 ChartData
|
||
chart = build_chart_data(
|
||
name=name, gender=gender, place=place, clock=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, childhood_luck=childhood,
|
||
)
|
||
|
||
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
|
||
chart_md = render(chart)
|
||
if day_boundary == "子正":
|
||
# render 层日界文案固定为子初口径,换盘子正时改写警示条与审计行
|
||
chart_md = chart_md.replace(_WARN_ZICHU, _WARN_ZIZHENG)
|
||
chart_md = chart_md.replace(_AUDIT_ZICHU, _AUDIT_ZIZHENG)
|
||
|
||
pillars = [f"{chart.year.gan}{chart.year.zhi}",
|
||
f"{chart.month.gan}{chart.month.zhi}",
|
||
f"{chart.day.gan}{chart.day.zhi}",
|
||
f"{chart.hour.gan}{chart.hour.zhi}"]
|
||
return chart_md, pillars, chart.day_master, chart.true_solar_time
|
||
|
||
|
||
def _update_chart(a) -> int:
|
||
"""换盘模式:重排并替换既有档案的「## 盘面」段,frontmatter 与主键名同步。"""
|
||
path = Path(a.update_chart).expanduser().resolve()
|
||
if not path.is_file():
|
||
print(f"ERROR: 档案不存在:{path}", file=sys.stderr)
|
||
return 2
|
||
meta, body = store.parse_frontmatter(path.read_text(encoding="utf-8"))
|
||
if not meta:
|
||
print("ERROR: 档案无 frontmatter,无法换盘;请检查文件或用完整建档。",
|
||
file=sys.stderr)
|
||
return 2
|
||
name = str(meta.get("name", "")).strip()
|
||
gender = str(meta.get("gender", "")).strip()
|
||
birth = (a.birth or str(meta.get("birth_clock", ""))).strip()
|
||
place = (a.place or str(meta.get("birthplace", ""))).strip()
|
||
if not name or gender not in ("男", "女") or not birth or not place:
|
||
print("ERROR: 档案 frontmatter 缺 name/gender/birth_clock/birthplace,"
|
||
"无法重排;请补全档案或改用完整建档。", file=sys.stderr)
|
||
return 2
|
||
|
||
from geo.cities import LocationError
|
||
try:
|
||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||
name, gender, birth, place, a.day_boundary)
|
||
except LocationError as e:
|
||
print(f"ERROR: {e}", file=sys.stderr)
|
||
return 2
|
||
except ValueError:
|
||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||
return 2
|
||
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
old_pillars = meta.get("pillars", [])
|
||
if isinstance(old_pillars, str):
|
||
old_pillars = old_pillars.split()
|
||
meta.update({
|
||
"name": name, "gender": gender, "birth_clock": birth, "birthplace": place,
|
||
"true_solar_time": tst_str, "pillars": pillars, "day_master": day_master,
|
||
"created_at": meta.get("created_at") or now, "updated_at": now,
|
||
})
|
||
body = store.replace_section(body, "盘面", chart_md)
|
||
|
||
# 四柱/hash6 变动时改为新主键名;目标已存在则拒绝覆盖
|
||
new_path = path.with_name(store.archive_name(
|
||
pillars, gender, store.key_hash(birth, place, gender)))
|
||
if new_path != path:
|
||
if new_path.exists():
|
||
print(f"ERROR: 新盘主键档案已存在:{new_path};"
|
||
f"为避免覆盖,本次未写入。请先核对两份档案。", file=sys.stderr)
|
||
return 2
|
||
new_path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||
path.unlink()
|
||
else:
|
||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||
|
||
changed = [f"{_PILLAR_LABELS[i]} {old_pillars[i]}→{pillars[i]}"
|
||
for i in range(min(len(old_pillars), 4))
|
||
if old_pillars[i] != pillars[i]]
|
||
if changed:
|
||
print(f"注意:{'、'.join(changed)} 已变。受变动柱影响的已有推断条目"
|
||
f"(归纳、流年大事)须复核并标【待复核】,复核前不得继续引用;"
|
||
f"旧盘四柱与换盘原因请记入「## 反馈与澄清」。", file=sys.stderr)
|
||
print(new_path)
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(
|
||
prog="memory_save.py",
|
||
description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)",
|
||
)
|
||
ap.add_argument("--name", default=None, help="姓名")
|
||
ap.add_argument("--gender", default=None, choices=["男", "女"],
|
||
help="性别:男 或 女")
|
||
ap.add_argument("--birth", default=None,
|
||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
|
||
"换盘模式下为时辰校正覆盖值")
|
||
ap.add_argument("--place", default=None,
|
||
help="出生地,精确到地级市一级,如「四川省成都市」;"
|
||
"换盘模式下为地点校正覆盖值")
|
||
ap.add_argument("--update-chart", default=None, metavar="档案路径",
|
||
help="换盘模式:按档案重排并替换其「## 盘面」段,"
|
||
"frontmatter 与文件名同步为新盘")
|
||
ap.add_argument("--day-boundary", default="子初", choices=["子初", "子正"],
|
||
help="日界规则:子初(默认,真太阳时 23:00 换日)或 子正(0:00 换日);"
|
||
"仅换盘模式可用")
|
||
ap.add_argument("--memory-root", default=None,
|
||
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
|
||
a = ap.parse_args()
|
||
|
||
if a.update_chart is None:
|
||
missing = [k for k in ("name", "gender", "birth", "place")
|
||
if getattr(a, k) is None]
|
||
if missing:
|
||
print(f"ERROR: 建档缺参数:{'、'.join('--' + k for k in missing)}。"
|
||
f"(换盘请用 --update-chart <档案路径>)", file=sys.stderr)
|
||
return 2
|
||
if a.day_boundary != "子初":
|
||
print("ERROR: --day-boundary 仅换盘模式(--update-chart)可用;"
|
||
"建档固定子初换日。", file=sys.stderr)
|
||
return 2
|
||
elif a.name is not None or a.gender is not None:
|
||
print("ERROR: 换盘模式不支持 --name/--gender(姓名、性别以档案为准)。",
|
||
file=sys.stderr)
|
||
return 2
|
||
|
||
# 软授权锁:与 cast_chart.py 同位置——建档/换盘即一次排盘,先 check,成功后 consume
|
||
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:
|
||
if a.update_chart is not None:
|
||
rc = _update_chart(a)
|
||
if rc == 0:
|
||
_gate.consume(Path(__file__).resolve().parents[1])
|
||
return rc
|
||
|
||
# L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
|
||
from geo.cities import LocationError
|
||
try:
|
||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||
a.name, a.gender, a.birth, a.place)
|
||
except LocationError as e:
|
||
print(f"ERROR: {e}", file=sys.stderr)
|
||
return 2
|
||
except ValueError:
|
||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||
return 2
|
||
|
||
# —— 建档/更新 ——
|
||
root = store.resolve_root(a.memory_root, __file__)
|
||
arch_dir = root / "archives"
|
||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||
path = arch_dir / store.archive_name(
|
||
pillars, a.gender, store.key_hash(a.birth, a.place, a.gender))
|
||
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
meta = {
|
||
"name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(),
|
||
"birthplace": a.place.strip(), "true_solar_time": tst_str,
|
||
"pillars": pillars, "day_master": day_master,
|
||
"created_at": now, "updated_at": now,
|
||
}
|
||
if path.is_file():
|
||
old_meta, body = store.parse_frontmatter(
|
||
path.read_text(encoding="utf-8"))
|
||
meta["created_at"] = old_meta.get("created_at") or now
|
||
body = store.replace_section(body, "盘面", chart_md)
|
||
else:
|
||
body = store.skeleton(a.name, a.gender, chart_md)
|
||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||
|
||
_gate.consume(Path(__file__).resolve().parents[1])
|
||
print(path)
|
||
return 0
|
||
except SystemExit:
|
||
raise
|
||
except Exception:
|
||
print("ERROR: 建档内部错误。请把以下信息原样报告给用户,"
|
||
"不要自行猜测命盘。", file=sys.stderr)
|
||
traceback.print_exc(file=sys.stderr)
|
||
return 3
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|