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:
co-authored by
Claude Fable 5.1
parent
15be6a0631
commit
7c5ea7ae5a
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user