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
202 lines
6.9 KiB
Python
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
|