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
@@ -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"
|
||||
Reference in New Issue
Block a user