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
+168
View File
@@ -0,0 +1,168 @@
"""Solar-return instant localisation and relocated annual charts (upstream 41d1c740).
Regression for BUG-1095: ``find_solar_return_ut`` returned ``dt_local == dt_ut``
("调用方另行加 tz") and no caller ever added the offset, so the Varshapravesha
shown to readers and the Saham day/night instant were the UT clock labelled as
local (8 hours off for a Beijing chart). Fictional charts only.
"""
from __future__ import annotations
import io
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import solar_return # noqa: E402
from cmd_solar_return import solar_return_location_kwargs # noqa: E402
from annual_tajika_pack import _args_from_payload # noqa: E402
try: # pragma: no cover - environment probe
import swisseph # noqa: F401
HAS_SWE = True
except Exception: # pragma: no cover
HAS_SWE = False
BEIJING = dict(birth_year=1990, birth_month=6, birth_day=15, birth_hour=10, birth_minute=30,
birth_lat=39.9042, birth_lon=116.4074, birth_tz=8.0, target_year=2026)
NEW_YORK = dict(birth_year=1985, birth_month=7, birth_day=4, birth_hour=8, birth_minute=0,
birth_lat=40.7128, birth_lon=-74.006, birth_tz=-5.0, target_year=2026)
def _dt(value) -> datetime:
return value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
def test_localize_return_datetime_uses_zone_offset_at_the_instant() -> None:
instant = datetime(2026, 7, 5, 1, 8, 3) # UT, northern-hemisphere summer
zoned = solar_return._localize_return_datetime(instant, -5.0, "America/New_York")
fixed = solar_return._localize_return_datetime(instant, -5.0, None)
assert zoned.utcoffset() == timedelta(hours=-4) # EDT at that instant
assert fixed.utcoffset() == timedelta(hours=-5) # the supplied fixed offset
assert zoned.replace(tzinfo=None) - fixed.replace(tzinfo=None) == timedelta(hours=1)
def test_localize_return_datetime_winter_instant_matches_standard_offset() -> None:
instant = datetime(2026, 1, 15, 12, 0, 0)
zoned = solar_return._localize_return_datetime(instant, -5.0, "America/New_York")
assert zoned.utcoffset() == timedelta(hours=-5)
@pytest.mark.skipif(not HAS_SWE, reason="swisseph required")
def test_beijing_return_local_time_is_ut_plus_eight() -> None:
report = solar_return.solar_return_full_report(**BEIJING)
sr = report["solar_return"]
assert "error" not in report
assert _dt(sr["dt_local"]) - _dt(sr["dt_ut"]) == timedelta(hours=8)
assert report["annual_location"] == {
"latitude": BEIJING["birth_lat"], "longitude": BEIJING["birth_lon"],
"utc_offset": 8.0, "timezone_id": None,
}
# Saham day/night must be judged at the true return instant, not UT-as-local.
assert report["sahams"]["daynight_evidence"]["julian_day_ut"] == pytest.approx(sr["jd_ut"], abs=1e-5)
@pytest.mark.skipif(not HAS_SWE, reason="swisseph required")
def test_beijing_iana_zone_equals_fixed_offset_where_there_is_no_dst() -> None:
fixed = solar_return.solar_return_full_report(**BEIJING)
zoned = solar_return.solar_return_full_report(**BEIJING, local_timezone_id="Asia/Shanghai")
assert zoned["solar_return"]["dt_local"] == fixed["solar_return"]["dt_local"]
assert zoned["annual_location"]["utc_offset"] == 8.0
assert zoned["annual_location"]["timezone_id"] == "Asia/Shanghai"
assert zoned["sr_chart_info"] == fixed["sr_chart_info"]
@pytest.mark.skipif(not HAS_SWE, reason="swisseph required")
def test_new_york_summer_return_is_one_hour_later_with_iana_zone() -> None:
fixed = solar_return.solar_return_full_report(**NEW_YORK)
zoned = solar_return.solar_return_full_report(**NEW_YORK, local_timezone_id="America/New_York")
assert fixed["solar_return"]["dt_ut"] == zoned["solar_return"]["dt_ut"]
assert _dt(zoned["solar_return"]["dt_local"]) - _dt(fixed["solar_return"]["dt_local"]) == timedelta(hours=1)
assert fixed["annual_location"]["utc_offset"] == -5.0
assert zoned["annual_location"]["utc_offset"] == -4.0
assert zoned["sr_chart_info"] == fixed["sr_chart_info"] # the chart itself is cast in UT
@pytest.mark.skipif(not HAS_SWE, reason="swisseph required")
def test_relocated_return_casts_the_annual_chart_at_the_current_location() -> None:
home = solar_return.solar_return_full_report(**NEW_YORK)
london = solar_return.solar_return_full_report(
**NEW_YORK, return_lat=51.5074, return_lon=-0.1278, return_tz=0.0, local_timezone_id="Europe/London",
)
assert london["solar_return"]["dt_ut"] == home["solar_return"]["dt_ut"]
assert london["annual_location"]["latitude"] == 51.5074
assert london["annual_location"]["utc_offset"] == 1.0 # BST at the July instant
assert london["sr_chart_info"]["asc_sign"] != home["sr_chart_info"]["asc_sign"]
@pytest.mark.skipif(not HAS_SWE, reason="swisseph required")
def test_incomplete_return_location_is_reported_not_guessed() -> None:
report = solar_return.calc_solar_return_chart(**NEW_YORK, return_lat=51.5)
assert "return_location_incomplete" in report["error"]
report = solar_return.calc_solar_return_chart(**NEW_YORK, return_lat=51.5, return_lon=-0.1)
assert "return_location_timezone_missing" in report["error"]
def test_location_kwargs_accept_cli_and_annual_pack_namespaces() -> None:
cli = SimpleNamespace(solar_return_location_mode="current_location", current_lat=51.5, current_lon=-0.1,
current_tz=0.0, current_timezone_id="Europe/London")
assert solar_return_location_kwargs(cli) == {
"local_timezone_id": "Europe/London", "return_lat": 51.5, "return_lon": -0.1, "return_tz": 0.0,
}
pack = SimpleNamespace(return_lat=51.5, return_lon=-0.1, return_tz=0.0, return_timezone_id=None)
assert solar_return_location_kwargs(pack)["return_lat"] == 51.5
birth_zone = SimpleNamespace(timezone_id="Asia/Shanghai")
assert solar_return_location_kwargs(birth_zone) == {"local_timezone_id": "Asia/Shanghai"}
assert solar_return_location_kwargs(SimpleNamespace()) == {"local_timezone_id": None}
with pytest.raises(ValueError):
solar_return_location_kwargs(SimpleNamespace(solar_return_location_mode="current_location", current_lat=1.0))
def test_annual_pack_args_carry_the_return_location_and_birth_zone() -> None:
payload = {
"birth": {"date": "1985-07-04", "time": "08:00:00", "latitude": 40.7128, "longitude": -74.006,
"utc_offset": "-05:00", "timezone": "America/New_York"},
"settings": {},
"target_year": 2026,
}
args = _args_from_payload(payload)
assert args.return_lat is None and args.return_tz is None
assert args.return_timezone_id == "America/New_York"
payload["birth"]["timezone"] = "UTC-05:00"
assert _args_from_payload(payload).return_timezone_id is None
payload["settings"] = {
"solar_return_location_mode": "current_location",
"current_location": {"latitude": 51.5, "longitude": -0.1, "utc_offset": "+00:00", "timezone_id": "Europe/London"},
}
args = _args_from_payload(payload)
assert (args.return_lat, args.return_lon, args.return_tz, args.return_timezone_id) == (51.5, -0.1, 0.0, "Europe/London")
payload["settings"]["current_location"] = {"latitude": 51.5}
with pytest.raises(ValueError):
_args_from_payload(payload)
def test_output_json_is_compact_only_for_pl9_exports() -> None:
import jyotish_engine
pl9 = {"schema": "pl9_style_professional_export_v1", "a": [1, 2, {"b": "中文"}]}
other = {"schema": "other", "a": [1, 2]}
buf = io.StringIO()
jyotish_engine.output_json(pl9, stream=buf)
text = buf.getvalue()
assert text == '{"schema":"pl9_style_professional_export_v1","a":[1,2,{"b":"中文"}]}\n'
assert json.loads(text) == pl9
buf = io.StringIO()
jyotish_engine.output_json(other, stream=buf)
assert buf.getvalue() == json.dumps(other, ensure_ascii=False, indent=2) + "\n"