feat(annual): localise the solar-return instant, relocated returns, compact PL9 export (BUG-1095..1097)

Upstream yinduzhanxing 41d1c740 / 59c9139f, hand-merged; 089858c0 verified not applicable.

- solar_return: dt_local was the UT instant verbatim (no caller ever added the
  offset). Localise with ZoneInfo at the return instant (IANA id) or the fixed
  offset; expose annual_location; Sahams day/night at the true instant;
  optional current_location return charts.
- cmd_solar_return / annual_tajika_pack / engine: pass birth IANA zone and
  --solar-return-location-mode / --current-* through; external PyJHora
  replay blocked when relocated.
- output_json keeps the upstream stream= signature; PL9 exports written
  compact via dumps (measured faster than json.dump streaming), 9.57 -> 4.61 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-29 14:59:08 +08:00
co-authored by Claude Fable 5.1
parent 15be6a0631
commit 7c5ea7ae5a
9 changed files with 516 additions and 18 deletions
+25
View File
@@ -8,6 +8,7 @@ the next conflict-gate layer.
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from types import SimpleNamespace
from typing import Any
@@ -80,9 +81,29 @@ def build_annual_tajika_pack(
return pack
def _iana_timezone_id(value: Any) -> str | None:
"""Return ``value`` when it names a loadable IANA zone, else ``None``."""
if not isinstance(value, str) or "/" not in value:
return None
try:
ZoneInfo(value.strip())
except Exception:
return None
return value.strip()
def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace:
birth = dict(payload.get("birth") or {})
settings = dict(payload.get("settings") or {})
return_location: dict[str, Any] = {}
if settings.get("solar_return_location_mode") == "current_location":
return_location = dict(settings.get("current_location") or {})
if any(return_location.get(key) is None for key in ("latitude", "longitude", "utc_offset")):
raise ValueError("current_location requires latitude, longitude and utc_offset")
return_timezone_id = (
_iana_timezone_id(return_location.get("timezone_id")) if return_location
else _iana_timezone_id(birth.get("timezone_id") or birth.get("timezone"))
)
year, month, day = [int(part) for part in str(birth["date"]).split("-")]
time_parts = [int(part) for part in str(birth.get("time") or "00:00:00").split(":")]
while len(time_parts) < 3:
@@ -110,6 +131,10 @@ def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace:
dasha_year_days=settings.get("dasha_year_days", 365.25),
solar_return_location_mode=settings.get("solar_return_location_mode", "birth_place"),
annual_year_policy=settings.get("annual_year_policy", "solar_return_exact"),
return_lat=return_location.get("latitude"),
return_lon=return_location.get("longitude"),
return_tz=_offset_to_hours(return_location["utc_offset"]) if return_location else None,
return_timezone_id=return_timezone_id,
)
+36
View File
@@ -43,9 +43,45 @@ def cmd_solar_return(args: Any) -> Dict[str, Any]:
args.lat, args.lon, args.tz,
args.target_year,
ayanamsa_name=getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME,
**solar_return_location_kwargs(args),
)
def solar_return_location_kwargs(args: Any) -> Dict[str, Any]:
"""Return-location kwargs for ``solar_return_full_report``.
Two arg shapes are accepted: the annual-pack namespace (``return_lat`` /
``return_lon`` / ``return_tz`` / ``return_timezone_id``, built by
``annual_tajika_pack._args_from_payload``) and the CLI namespace
(``--solar-return-location-mode`` / ``--current-*`` / ``--timezone-id``).
Upstream yinduzhanxing 41d1c740.
"""
if getattr(args, 'return_lat', None) is not None or getattr(args, 'return_timezone_id', None):
return {
'local_timezone_id': getattr(args, 'return_timezone_id', None),
'return_lat': getattr(args, 'return_lat', None),
'return_lon': getattr(args, 'return_lon', None),
'return_tz': getattr(args, 'return_tz', None),
}
mode = getattr(args, 'solar_return_location_mode', None) or 'birth_place'
if mode == 'current_location':
if any(getattr(args, key, None) is None for key in ('current_lat', 'current_lon', 'current_tz')):
raise ValueError('current_location requires current_lat, current_lon and current_tz')
return {
'local_timezone_id': getattr(args, 'current_timezone_id', None),
'return_lat': float(args.current_lat),
'return_lon': float(args.current_lon),
'return_tz': float(args.current_tz),
}
# CLI passes ``--timezone-id``; API payloads reach here as ``timezone`` /
# ``timezone_name`` (calculation_profile_contract keys). Only IANA ids count.
for key in ('timezone_id', 'timezone', 'timezone_name'):
value = getattr(args, key, None)
if isinstance(value, str) and '/' in value:
return {'local_timezone_id': value.strip()}
return {'local_timezone_id': None}
if __name__ == '__main__':
# 独立测试入口
import argparse
+60 -10
View File
@@ -765,9 +765,22 @@ def _calc_sidereal_planets_for_jd(jd, node_mode='mean', include_ketu=True):
return data, ayanamsa
def output_json(data):
"""统一JSON输出"""
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
def output_json(data, *, stream=None):
"""统一JSON输出。
PL9 专业导出(``pl9_style_professional_export_v1``)用紧凑分隔符、不缩进,
其他命令保持 ``indent=2``;``stream`` 签名与上游 yinduzhanxing 59c9139f 一致。
"""
stream = sys.stdout if stream is None else stream
is_pl9 = isinstance(data, dict) and data.get('schema') == 'pl9_style_professional_export_v1'
# 实测(9.6 MB 报告,CPython 3.13):json.dump 流式走纯 Python 编码器,比 dumps 慢 ~45%;
# 紧凑 dumps 走 C 编码器,字符串体积减半、峰值内存 47 MB → 22 MB、耗时 0.41 s → 0.30 s。
# 故保留上游的 stream 签名,但 PL9 用紧凑 dumps 一次写出。
if is_pl9:
stream.write(json.dumps(data, ensure_ascii=False, default=str, separators=(',', ':')))
else:
stream.write(json.dumps(data, ensure_ascii=False, indent=2, default=str))
stream.write('\n')
def _normalize_pl9_pack_selection(args) -> list[str]:
@@ -3015,12 +3028,17 @@ def _build_pl9_full_annual_section(args, profile: dict | None = None) -> dict:
longitude=float(location.get('longitude', args.lon)),
timezone=float(args.tz),
)
replay = replay_pyjhora_annual(
annual_profile,
birth_julian_day=birth_jd,
place=place,
age=age,
)
if payload['settings'].get('solar_return_location_mode') == 'current_location':
# The external adapter accepts one place for both natal and annual
# calculations. Do not present a birthplace replay as relocated parity.
replay = {'status': 'blocked', 'reason': 'separate_return_location_not_supported_by_external_adapter'}
else:
replay = replay_pyjhora_annual(
annual_profile,
birth_julian_day=birth_jd,
place=place,
age=age,
)
return build_annual_tajika_pack(payload, pyjhora_replay=replay)
except Exception as exc:
return {'schema': 'jyotish.annual_tajika_pack.v1', 'status': 'blocked', 'reason': str(exc)}
@@ -3053,6 +3071,18 @@ def _annual_payload_from_args(args, profile: dict | None = None) -> dict:
'target_year': int(args.target_year),
'age': getattr(args, 'age', None),
}
birth_timezone_id = getattr(args, 'timezone_id', None) or timezone.get('name')
if isinstance(birth_timezone_id, str) and '/' in birth_timezone_id:
payload['birth']['timezone_id'] = birth_timezone_id.strip()
if payload['settings']['solar_return_location_mode'] == 'current_location':
if any(getattr(args, key, None) is None for key in ('current_lat', 'current_lon', 'current_tz')):
raise ValueError('current_location requires current_lat, current_lon and current_tz')
payload['settings']['current_location'] = {
'latitude': float(args.current_lat), 'longitude': float(args.current_lon),
'utc_offset': _pl9_utc_offset_from_tz(args.current_tz),
'timezone_id': getattr(args, 'current_timezone_id', None),
}
return payload
def _pl9_utc_offset_from_tz(tz_value) -> str:
@@ -3062,6 +3092,21 @@ def _pl9_utc_offset_from_tz(tz_value) -> str:
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
def _solar_return_location_kwargs(args) -> dict:
"""Solar-return localisation kwargs (shared with cmd_solar_return; upstream 41d1c740)."""
from cmd_solar_return import solar_return_location_kwargs
return solar_return_location_kwargs(args)
def _add_solar_return_location_args(p) -> None:
p.add_argument('--timezone-id', default=None, help='出生地 IANA 时区(如 Asia/Shanghai);用于返照时刻按夏令时规则本地化')
p.add_argument('--solar-return-location-mode', choices=['birth_place', 'current_location'], default='birth_place')
p.add_argument('--current-lat', type=float, default=None)
p.add_argument('--current-lon', type=float, default=None)
p.add_argument('--current-tz', type=float, default=None)
p.add_argument('--current-timezone-id', default=None, help='返照地 IANA 时区(仅 current_location 模式)')
def _is_domi_pl9_control_case(packet: dict) -> bool:
return False
@@ -15919,6 +15964,7 @@ def cmd_full_reading(args):
args.lat, args.lon, args.tz,
args.target_year,
ayanamsa_name=_current_ayanamsa_name(args),
**_solar_return_location_kwargs(args),
)
report['modules']['solar_return'] = sr_result
# 更新 muntha 为正确值(来自太阳返照盘)
@@ -18179,6 +18225,7 @@ def main():
p = sub.add_parser('solar-return', help='太阳返照盘 Varshaphala 年运分析')
_add_chart_args(p)
p.add_argument('--target-year', type=int, required=True, help='目标年份(计算该年太阳返照)')
_add_solar_return_location_args(p)
# 21.6 narayana-dasha (v6.0.20新增)
p = sub.add_parser('narayana-dasha', help='Narayana Dasha(Rishi Dasha)星座大运分析')
@@ -18212,6 +18259,7 @@ def main():
# 22. full-reading (v3.7.1新增)
p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)')
_add_chart_args(p)
_add_solar_return_location_args(p)
p.add_argument('--age', type=int, default=None, help='当前年龄(不提供则自动计算)')
p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)')
p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)')
@@ -18227,6 +18275,7 @@ def main():
p = sub.add_parser('pl9-export', help='PL9 风格专业排盘导出(基于 full-reading 聚合专业模块)')
_add_chart_args(p)
_add_solar_return_location_args(p)
p.add_argument('--age', type=int, default=None, help='当前年龄(不提供则自动计算)')
p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)')
p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)')
@@ -18410,7 +18459,8 @@ def main():
if output_path:
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str) + '\n', encoding='utf-8')
with target.open('w', encoding='utf-8') as stream:
output_json(result, stream=stream)
output_json(result)
else:
output_json(result)
+49 -7
View File
@@ -15,7 +15,8 @@ Jyotish Vedic Astrology Skill — v6.0.18
"""
from typing import Dict, Optional, Tuple, List
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import math
import sys
import os
@@ -261,6 +262,7 @@ def _find_solar_return_approx(
birth_sun_lon: float,
target_year: int,
tz: float,
timezone_id: str | None = None,
) -> Dict:
"""
近似计算太阳返照时间(不使用 swisseph)。
@@ -272,7 +274,7 @@ def _find_solar_return_approx(
except ValueError:
approx_dt_utc = datetime(target_year, birth_dt.month, 1, 12, 0, 0)
dt_local = approx_dt_utc + timedelta(hours=tz)
dt_local = _localize_return_datetime(approx_dt_utc, tz, timezone_id).replace(tzinfo=None)
return {
'jd_ut': _datetime_to_jd_ut(approx_dt_utc),
@@ -291,12 +293,27 @@ def _find_solar_return_approx(
# 生成完整太阳返照盘
# =========================================================================
def _localize_return_datetime(dt_ut, utc_offset, timezone_id=None):
"""Convert the return instant using the zone's offset at that instant.
With an IANA ``timezone_id`` the offset is taken from the zone rules at the
return instant (DST-aware). Without one, the fixed ``utc_offset`` is used.
Upstream yinduzhanxing 41d1c740.
"""
zone = ZoneInfo(timezone_id) if timezone_id else timezone(timedelta(hours=float(utc_offset)))
return dt_ut.replace(tzinfo=timezone.utc).astimezone(zone)
def calc_solar_return_chart(
birth_year: int, birth_month: int, birth_day: int,
birth_hour: int, birth_minute: int,
birth_lat: float, birth_lon: float, birth_tz: float,
target_year: int,
ayanamsa_name: str = 'raman',
local_timezone_id: str | None = None,
return_lat: float | None = None,
return_lon: float | None = None,
return_tz: float | None = None,
) -> Dict:
"""
计算太阳返照盘(Varshaphala)。
@@ -325,6 +342,13 @@ def calc_solar_return_chart(
# → 改为由调用方传入 birth_sun_lon,或从出生盘数据里取
# 这里我们先做:调用 engine 的 compute_chart_data 算出生盘
try:
if (return_lat is None) != (return_lon is None):
raise ValueError('return_location_incomplete')
if return_lat is not None and return_tz is None and not local_timezone_id:
raise ValueError('return_location_timezone_missing')
annual_lat = float(birth_lat if return_lat is None else return_lat)
annual_lon = float(birth_lon if return_lon is None else return_lon)
annual_tz = float(birth_tz if return_tz is None else return_tz)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from jyotish_engine import compute_chart_data, HAS_SWE as ENGINE_HAS_SWE
@@ -337,7 +361,8 @@ def calc_solar_return_chart(
birth_hour, birth_minute, 0)),
0, # birth_sun_lon unknown
target_year,
birth_tz
annual_tz,
timezone_id=local_timezone_id,
),
}
@@ -368,6 +393,14 @@ def calc_solar_return_chart(
sr_jd_ut = sr_result['jd_ut']
sr_dt_ut = sr_result['dt_ut']
# 返照时刻本地化:按返照瞬间的时区偏移(IANA 区支持夏令时),而不是
# 把 UT 直接当本地时间(旧行为 dt_local == dt_ut)。
local_dt = _localize_return_datetime(sr_dt_ut, annual_tz, local_timezone_id)
annual_tz = local_dt.utcoffset().total_seconds() / 3600
sr_result['dt_local'] = local_dt.replace(tzinfo=None)
sr_result['annual_location'] = {'latitude': annual_lat, 'longitude': annual_lon,
'utc_offset': annual_tz, 'timezone_id': local_timezone_id}
# Step 3: 以返照时刻计算星盘
# compute_chart_data 接受 year/month/day/hour/minute
sr_year, sr_month, sr_day = sr_dt_ut.year, sr_dt_ut.month, sr_dt_ut.day
@@ -378,7 +411,7 @@ def calc_solar_return_chart(
sr_chart, sr_asc_idx, sr_jd, sr_ayanamsa = compute_chart_data(
sr_year, sr_month, sr_day,
sr_hour_int, sr_minute_int,
birth_lat, birth_lon, 0, # UT 时间,时区=0
annual_lat, annual_lon, 0, # UT 时间,时区=0
'mean',
ayanamsa_name=ayanamsa_name,
)
@@ -404,6 +437,8 @@ def calc_solar_return_chart(
'birth_asc_sign_idx': birth_asc_idx,
'target_year': target_year,
'birth_year': birth_year,
'annual_location': {'latitude': annual_lat, 'longitude': annual_lon,
'utc_offset': annual_tz, 'timezone_id': local_timezone_id},
'age': target_year - birth_year,
'note': f'太阳返照盘 {target_year} 年(年龄 {target_year-birth_year} 岁)',
}
@@ -422,6 +457,10 @@ def solar_return_full_report(
birth_lat: float, birth_lon: float, birth_tz: float,
target_year: int,
ayanamsa_name: str = 'raman',
local_timezone_id: str | None = None,
return_lat: float | None = None,
return_lon: float | None = None,
return_tz: float | None = None,
) -> Dict:
"""
太阳返照盘完整报告(Varshaphala 年运分析)。
@@ -449,6 +488,8 @@ def solar_return_full_report(
birth_lat, birth_lon, birth_tz,
target_year,
ayanamsa_name=ayanamsa_name,
local_timezone_id=local_timezone_id,
return_lat=return_lat, return_lon=return_lon, return_tz=return_tz,
)
if 'error' in sr:
@@ -469,6 +510,7 @@ def solar_return_full_report(
}
else:
result['solar_return'] = sr['solar_return']
result['annual_location'] = dict(sr.get('annual_location') or {})
result['sr_chart_info'] = {
'asc_sign': SIGNS[sr['asc_sign_idx']],
'asc_sign_idx': sr['asc_sign_idx'],
@@ -549,9 +591,9 @@ def solar_return_full_report(
asc_lon,
sr_dt_local,
chart_type='varsha',
lat=birth_lat,
lon=birth_lon,
tz=birth_tz,
lat=sr.get('annual_location', {}).get('latitude', birth_lat),
lon=sr.get('annual_location', {}).get('longitude', birth_lon),
tz=sr.get('annual_location', {}).get('utc_offset', birth_tz),
)
result['sahams'] = sahams
except Exception as e: