Files
ZiPingEightChar/scripts/memory_save.py
T
moxiaonian b20fd5c3c9 v3.6.2 同步发版:晚子时不再双重换日、港澳时区归位
- 晚子时(真太阳时 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…
2026-10-12 19:05:09 +08:00

317 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())