Files
Jyotisha/scripts/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

182 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""The real sky over a birth place at the birth instant (「那一刻的天空」).
Pure computation for the birth-sky cover (TASK-birth-sky-cover-20260928 T1).
Thinly registered as ``POST /api/birth_sky`` from ``jyotish_api_server.py``.
Conventions
-----------
* Planets: tropical apparent ecliptic positions from ``swe.calc_ut`` (default
flags), converted with ``swe.azalt(..., swe.ECL2HOR, ...)``.
* Stars: Yale Bright Star Catalogue (J2000, V <= 4.0, ``data/bright_stars.json``),
precessed to the birth epoch with IAU 1976 (Lieske) precession, then
``swe.azalt(..., swe.EQU2HOR, ...)``. Proper motion and nutation are not
applied: at this drawing's scale they are far below one pixel.
* Altitude is the **geometric (true) altitude**: no atmospheric refraction
(``atpress = 0``), so the golden values are reproducible anywhere.
* Azimuth is returned **from north, increasing eastward**. Swiss Ephemeris
``azalt`` measures azimuth from **south** (westward), so we add 180°.
The response never echoes the input date, time, or coordinates.
"""
from __future__ import annotations
import json
import math
from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
try:
import swisseph as swe
except ImportError: # pragma: no cover
swe = None
STAR_FILE = Path(__file__).resolve().parent / "data" / "bright_stars.json"
PLANETS = (
("sun", "SUN"),
("moon", "MOON"),
("mercury", "MERCURY"),
("venus", "VENUS"),
("mars", "MARS"),
("jupiter", "JUPITER"),
("saturn", "SATURN"),
)
TWILIGHT_LIMIT = -18.0
J2000 = 2451545.0
class BirthSkyError(ValueError):
"""Bad input. The HTTP layer maps it to 400."""
@lru_cache(maxsize=1)
def _catalog() -> dict[str, Any]:
return json.loads(STAR_FILE.read_text(encoding="utf-8"))
def _number(body: dict[str, Any], key: str, low: float, high: float) -> float:
value = body.get(key)
if isinstance(value, bool) or value is None or value == "":
raise BirthSkyError(f"{key} is required")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise BirthSkyError(f"{key} must be a number") from exc
if not math.isfinite(number) or number < low or number > high:
raise BirthSkyError(f"{key} out of range")
return number
def _instant(date: str, time: str, tz_offset_hours: float) -> datetime:
if not isinstance(date, str) or not isinstance(time, str):
raise BirthSkyError("date and time are required")
clock = time.strip()[:8]
for pattern in ("%H:%M:%S", "%H:%M"):
try:
local = datetime.strptime(f"{date.strip()[:10]} {clock}", f"%Y-%m-%d {pattern}")
break
except ValueError:
continue
else:
raise BirthSkyError("date must be YYYY-MM-DD and time HH:MM")
return local - timedelta(hours=tz_offset_hours)
def _julian_day_ut(utc: datetime) -> float:
hours = utc.hour + utc.minute / 60 + utc.second / 3600
return swe.julday(utc.year, utc.month, utc.day, hours)
def precess_j2000(ra_deg: float, dec_deg: float, jd: float) -> tuple[float, float]:
"""IAU 1976 precession of a J2000 mean position to the mean equator of ``jd``."""
t = (jd - J2000) / 36525.0
arcsec = math.pi / (180 * 3600)
zeta = (2306.2181 * t + 0.30188 * t * t + 0.017998 * t ** 3) * arcsec
z = (2306.2181 * t + 1.09468 * t * t + 0.018203 * t ** 3) * arcsec
theta = (2004.3109 * t - 0.42665 * t * t - 0.041833 * t ** 3) * arcsec
ra0 = math.radians(ra_deg)
dec0 = math.radians(dec_deg)
a = math.cos(dec0) * math.sin(ra0 + zeta)
b = math.cos(theta) * math.cos(dec0) * math.cos(ra0 + zeta) - math.sin(theta) * math.sin(dec0)
c = math.sin(theta) * math.cos(dec0) * math.cos(ra0 + zeta) + math.cos(theta) * math.sin(dec0)
ra = (math.degrees(math.atan2(a, b) + z)) % 360.0
dec = math.degrees(math.asin(max(-1.0, min(1.0, c))))
return ra, dec
def _horizontal(jd: float, flag: int, geopos: tuple[float, float, float], xin: tuple[float, float, float]) -> tuple[float, float]:
# swe.azalt returns (azimuth from SOUTH, true altitude, apparent altitude).
azimuth_from_south, true_altitude, _apparent = swe.azalt(jd, flag, geopos, 0, 10, xin)
azimuth_from_north = (azimuth_from_south + 180.0) % 360.0
return true_altitude, azimuth_from_north
def _phase(sun_altitude: float, sun_hour_angle: float) -> str:
if sun_altitude > 0:
return "day"
if sun_altitude >= TWILIGHT_LIMIT:
# Hour angle 0..180 = the Sun is west of the meridian: local apparent afternoon.
return "twilight_evening" if 0 <= sun_hour_angle < 180 else "twilight_morning"
return "night"
def compute_birth_sky(date: str, time: str, tz_offset_hours: float, lat: float, lon: float) -> dict[str, Any]:
"""Altitude / azimuth of the seven visible grahas and bright stars at one instant."""
if swe is None: # pragma: no cover
raise RuntimeError("pyswisseph is not installed")
jd = _julian_day_ut(_instant(date, time, tz_offset_hours))
geopos = (float(lon), float(lat), 0.0)
planets: list[dict[str, Any]] = []
sun_altitude = 0.0
sun_ra = 0.0
for key, attr in PLANETS:
body = getattr(swe, attr)
ecliptic, _ = swe.calc_ut(jd, body)
altitude, azimuth = _horizontal(jd, swe.ECL2HOR, geopos, (ecliptic[0], ecliptic[1], ecliptic[2]))
planets.append({"id": key, "altitude": round(altitude, 2), "azimuth": round(azimuth, 2)})
if key == "sun":
sun_altitude = altitude
equatorial, _ = swe.calc_ut(jd, body, swe.FLG_EQUATORIAL)
sun_ra = equatorial[0]
local_sidereal_deg = (swe.sidtime(jd) * 15.0 + lon) % 360.0
sun_hour_angle = (local_sidereal_deg - sun_ra) % 360.0
catalog = _catalog()
stars: list[dict[str, Any]] = []
for hr, ra, dec, vmag in catalog["stars"]:
ra_date, dec_date = precess_j2000(ra, dec, jd)
altitude, azimuth = _horizontal(jd, swe.EQU2HOR, geopos, (ra_date, dec_date, 1.0))
if altitude <= 0:
continue
stars.append({"hr": hr, "altitude": round(altitude, 2), "azimuth": round(azimuth, 2), "magnitude": vmag})
return {
"phase": _phase(sun_altitude, sun_hour_angle),
"sun_altitude": round(sun_altitude, 2),
"planets": planets,
"stars": stars,
"lines": [{"id": line["id"], "strokes": line["strokes"]} for line in catalog["lines"]],
"conventions": {
"altitude": "geometric, no refraction",
"azimuth": "degrees from north, eastward",
"planets": "swe.calc_ut tropical apparent + swe.azalt ECL2HOR",
"stars": "Yale BSC5 V<=4.0, IAU 1976 precession + swe.azalt EQU2HOR",
},
}
def compute_birth_sky_from_request(body: Any) -> dict[str, Any]:
if not isinstance(body, dict):
raise BirthSkyError("body must be an object")
return compute_birth_sky(
str(body.get("date") or ""),
str(body.get("time") or ""),
_number(body, "timezone_offset", -14, 14),
_number(body, "latitude", -90, 90),
_number(body, "longitude", -180, 180),
)