Files
Jyotisha/tests/test_birth_sky.py
T
Jesse_ChenandClaude Opus 5.5 d277cbd8ad feat(chart): 「那一刻的天空」封面 — engine, BFF, canvas painter, chart header entry
T1 scripts/birth_sky.py: real alt/az of the seven visible grahas (swe.calc_ut +
swe.azalt, geometric altitude, azimuth converted from swe's south origin to
north-east) and Yale BSC5 stars V<=4.0 (IAU 1976 precession); phase
day / twilight_morning / twilight_evening / night. Thin POST /api/birth_sky
registration in do_POST (no new handler method, no __new__).

T2 GET /api/birth-sky?subject=: birth truth read server side through
loadSubjectBirth + prepareChartViewProfile; zod contract; no billing, no
writes, no birth data in the response.

T3/T4 lib/birth-sky: sentence table, pure Canvas 2D drawBirthSky (night /
paper palettes from DESIGN tokens, east on the left, serif headline with
1.5 s font wait), PNG export via navigator.share or Blob + a.download.
Chart page prefetches and paints the cover once the chart is ok; the header
button appears only when the PNG is ready. Dialog is a lazy chunk.

T7 DESIGN §17, VOICE, CONTEXT glossary, CHANGELOG, real-device checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-28 20:27:40 +08:00

202 lines
6.9 KiB
Python

"""Birth-sky cover computation (TASK-birth-sky-cover-20260928 T1).
Golden instant is the fictional sample from the brief: 1994-05-18 07:40 UTC+8,
30.27N 120.15E. Values are geometric altitudes (no refraction), azimuth from
north increasing eastward.
"""
from __future__ import annotations
import json
from io import BytesIO
import pytest
import swisseph as swe
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate
from scripts.birth_sky import (
STAR_FILE,
BirthSkyError,
compute_birth_sky,
compute_birth_sky_from_request,
precess_j2000,
)
from scripts.jyotish_api_server import (
DEFAULT_ALLOWED_HOSTS,
DEFAULT_ALLOWED_ORIGINS,
JyotishAPIHandler,
)
LAT = 30.27
LON = 120.15
def _sky(time: str = "07:40", date: str = "1994-05-18", lat: float = LAT, lon: float = LON, tz: float = 8):
return compute_birth_sky(date, time, tz, lat, lon)
def _planet(sky: dict, key: str) -> dict:
return next(item for item in sky["planets"] if item["id"] == key)
def test_golden_sample_sun_saturn_and_below_horizon_bodies() -> None:
sky = _sky()
sun = _planet(sky, "sun")
saturn = _planet(sky, "saturn")
assert sun["altitude"] == pytest.approx(31.7, abs=0.2)
assert sun["azimuth"] == pytest.approx(84.7, abs=0.3)
assert saturn["altitude"] == pytest.approx(50.3, abs=0.2)
assert saturn["azimuth"] == pytest.approx(191.1, abs=0.3)
assert _planet(sky, "moon")["altitude"] < 0
assert _planet(sky, "jupiter")["altitude"] < 0
assert sky["phase"] == "day"
assert sky["sun_altitude"] == pytest.approx(31.7, abs=0.2)
assert [item["id"] for item in sky["planets"]] == [
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
]
def test_response_does_not_echo_birth_inputs() -> None:
sky = _sky()
text = json.dumps(sky)
for key in ("date", "time", "latitude", "longitude", "timezone", "lat", "lon"):
assert f'"{key}"' not in text
assert "1994" not in text
assert "07:40" not in text
@pytest.mark.parametrize(
("date", "lat"),
[("1994-05-18", 30.27), ("2001-12-21", -33.87), ("2010-03-20", 51.5)],
)
def test_local_apparent_noon_altitude_matches_90_minus_lat_minus_declination(date: str, lat: float) -> None:
# Independent check: scan the local noon hour for the Sun's highest point
# and compare with 90 - |lat - dec|, dec from swe's equatorial position.
best = max(
(_sky(f"{hour:02d}:{minute:02d}", date=date, lat=lat)["sun_altitude"], hour, minute)
for hour in range(10, 15)
for minute in range(60)
)
altitude, hour, minute = best
jd = swe.julday(*map(int, date.split("-")), hour + minute / 60 - 8)
declination = swe.calc_ut(jd, swe.SUN, swe.FLG_EQUATORIAL)[0][1]
assert altitude == pytest.approx(90 - abs(lat - declination), abs=0.5)
def test_polaris_altitude_is_about_the_latitude() -> None:
polaris = next(star for star in _sky()["stars"] if star["hr"] == 424)
assert polaris["altitude"] == pytest.approx(30.0, abs=1.0)
assert polaris["azimuth"] < 3 or polaris["azimuth"] > 357
@pytest.mark.parametrize(
("time", "phase"),
[
("07:40", "day"),
("04:50", "twilight_morning"),
("19:10", "twilight_evening"),
("00:00", "night"),
],
)
def test_phase_has_four_states(time: str, phase: str) -> None:
sky = _sky(time)
assert sky["phase"] == phase
if phase.startswith("twilight"):
assert -18 <= sky["sun_altitude"] <= 0
def test_stars_are_above_horizon_and_lines_use_known_hr_numbers() -> None:
sky = _sky("00:00")
assert sky["stars"]
assert all(star["altitude"] > 0 for star in sky["stars"])
assert all(star["magnitude"] <= 4.0 for star in sky["stars"])
assert [line["id"] for line in sky["lines"]] == ["big_dipper", "cassiopeia", "orion"]
catalog = json.loads(STAR_FILE.read_text(encoding="utf-8"))
known = {row[0] for row in catalog["stars"]}
for line in sky["lines"]:
for stroke in line["strokes"]:
assert set(stroke) <= known
def test_star_catalogue_records_public_domain_source() -> None:
catalog = json.loads(STAR_FILE.read_text(encoding="utf-8"))
assert "Yale Bright Star Catalogue" in catalog["source"]
assert catalog["source_url"].startswith("https://cdsarc.cds.unistra.fr/ftp/V/50/")
assert len(catalog["source_sha256"]) == 64
assert "Public domain" in catalog["license"]
assert all(row[3] <= 4.0 for row in catalog["stars"])
assert len(catalog["stars"]) > 400
def test_iau_1976_precession_matches_meeus_example_21b() -> None:
# Meeus, Astronomical Algorithms, example 21.b (theta Persei, J2000 -> 2028-11-13.19).
ra, dec = precess_j2000(41.054063, 49.227750, 2462088.69)
assert ra == pytest.approx(41.547214, abs=1e-5)
assert dec == pytest.approx(49.348483, abs=1e-5)
def test_request_validation_rejects_bad_input() -> None:
good = {"date": "1994-05-18", "time": "07:40", "timezone_offset": 8, "latitude": LAT, "longitude": LON}
assert compute_birth_sky_from_request(good)["phase"] == "day"
for patch in ({"latitude": 91}, {"longitude": None}, {"date": "1994/05/18"}, {"time": "7h"}, {"timezone_offset": "x"}):
with pytest.raises(BirthSkyError):
compute_birth_sky_from_request({**good, **patch})
class _FakeHeaders(dict):
def get(self, key, default=None):
return super().get(key, default)
class _FakeServer:
allowed_origins = DEFAULT_ALLOWED_ORIGINS
allowed_hosts = DEFAULT_ALLOWED_HOSTS
class _PostCaptureHandler(JyotishAPIHandler):
def __init__(self, path: str, payload: dict) -> None:
raw = json.dumps(payload).encode("utf-8")
self.headers = _FakeHeaders({"Content-Length": str(len(raw)), "Content-Type": "application/json"})
self.server = _FakeServer()
self.path = path
self.rfile = BytesIO(raw)
self.wfile = BytesIO()
self.status_code = None
self.client_address = ("test-birth-sky", 0)
def send_response(self, code, message=None): # noqa: ANN001
self.status_code = code
def send_header(self, key, value): # noqa: ANN001
return None
def end_headers(self):
return None
def payload(self) -> dict:
return json.loads(self.wfile.getvalue().decode("utf-8"))
@pytest.fixture
def api_env(monkeypatch):
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "0")
reset_heavy_compute_gate()
yield
reset_heavy_compute_gate()
def test_http_endpoint_is_thinly_registered(api_env) -> None:
handler = _PostCaptureHandler(
"/api/birth_sky",
{"date": "1994-05-18", "time": "07:40", "timezone_offset": 8, "latitude": LAT, "longitude": LON},
)
handler.do_POST()
assert handler.status_code == 200
payload = handler.payload()
assert payload["phase"] == "day"
assert _planet(payload, "saturn")["azimuth"] == pytest.approx(191.1, abs=0.3)
bad = _PostCaptureHandler("/api/birth_sky", {"date": "1994-05-18", "time": "07:40"})
bad.do_POST()
assert bad.status_code == 400