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
This commit is contained in:
Jesse_Chen
2026-09-28 20:27:40 +08:00
co-authored by Claude Opus 5.5
parent d670521f47
commit d277cbd8ad
35 changed files with 2056 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
#!/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),
)
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Rebuild scripts/data/bright_stars.json from the Yale Bright Star Catalogue.
Source: Hoffleit & Warren, *The Bright Star Catalogue, 5th Revised Ed.*
(NASA Astronomical Data Center, 1991), distributed by CDS as VizieR V/50.
A US-government (NASA ADC) data product, redistributed by CDS without
restriction; treated as public domain. No star-chart software data is used.
Usage (the catalogue itself is not committed; the script checks its sha256):
curl -o /tmp/bsc5_catalog.gz https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
python3 scripts/build_bright_stars.py /tmp/bsc5_catalog.gz
"""
from __future__ import annotations
import gzip
import hashlib
import json
import sys
from pathlib import Path
SOURCE_URL = "https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz"
SOURCE_SHA256 = "3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed"
MAX_MAGNITUDE = 4.0
OUT = Path(__file__).resolve().parent / "data" / "bright_stars.json"
# Asterism polylines by HR number. Each inner list is one continuous stroke.
LINES = [
{"id": "big_dipper", "strokes": [[5191, 5054, 4905, 4660, 4554, 4295, 4301, 4660]]},
{"id": "cassiopeia", "strokes": [[21, 168, 264, 403, 542]]},
{
"id": "orion",
"strokes": [
[2061, 1879, 1790],
[2061, 1948, 1903, 1852, 1790],
[1948, 2004],
[1852, 1713],
],
},
]
def _parse(raw: bytes) -> list[dict]:
stars: list[dict] = []
for line in raw.decode("latin-1").splitlines():
if len(line) < 107 or not line[75:77].strip() or not line[102:107].strip():
continue # HR entries without a J2000 position or V magnitude (novae, removed objects)
vmag = float(line[102:107])
if vmag > MAX_MAGNITUDE:
continue
hr = int(line[0:4])
ra = (int(line[75:77]) + int(line[77:79]) / 60 + float(line[79:83]) / 3600) * 15
dec = int(line[84:86]) + int(line[86:88]) / 60 + int(line[88:90]) / 3600
if line[83] == "-":
dec = -dec
stars.append({"hr": hr, "ra": round(ra, 5), "dec": round(dec, 5), "vmag": vmag})
return stars
def main(path: str) -> None:
packed = Path(path).read_bytes()
digest = hashlib.sha256(packed).hexdigest()
if digest != SOURCE_SHA256:
raise SystemExit(f"sha256 mismatch: {digest}")
stars = _parse(gzip.decompress(packed))
known = {star["hr"] for star in stars}
for line in LINES:
for stroke in line["strokes"]:
missing = [hr for hr in stroke if hr not in known]
if missing:
raise SystemExit(f"{line['id']} needs HR {missing} brighter than {MAX_MAGNITUDE}")
payload = {
"source": "Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), VizieR V/50",
"source_url": SOURCE_URL,
"source_sha256": SOURCE_SHA256,
"license": "Public domain (NASA Astronomical Data Center product, redistributed by CDS without restriction)",
"selection": f"V magnitude <= {MAX_MAGNITUDE}; J2000.0 equinox and epoch; proper motion not applied",
"fields": ["hr", "ra_deg_j2000", "dec_deg_j2000", "vmag"],
"generator": "scripts/build_bright_stars.py",
"lines": LINES,
"stars": [[s["hr"], s["ra"], s["dec"], s["vmag"]] for s in stars],
}
OUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(f"{len(stars)} stars -> {OUT}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/bsc5_catalog.gz")
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
"""Rebuild frontend/tests/fixtures/birth-sky-golden.json from the real engine.
Fictional sample from TASK-birth-sky-cover-20260928: 1994-05-18, UTC+8,
30.27N 120.15E, at four local times (one per sky phase). The frontend tests
read these engine responses as-is; the test suite never writes this file.
python3 -m scripts.generate_birth_sky_golden
"""
from __future__ import annotations
import json
from pathlib import Path
from scripts.birth_sky import compute_birth_sky
ROOT = Path(__file__).resolve().parents[1]
GOLDEN = ROOT / "frontend" / "tests" / "fixtures" / "birth-sky-golden.json"
SAMPLE = {"date": "1994-05-18", "tz": 8, "lat": 30.27, "lon": 120.15}
TIMES = {"day": "07:40", "twilight_morning": "04:50", "twilight_evening": "19:10", "night": "00:00"}
def main() -> None:
payload = {
"source": "scripts/generate_birth_sky_golden.py -> scripts/birth_sky.compute_birth_sky (POST /api/birth_sky)",
"note": "Fictional sample 1994-05-18 UTC+8 30.27N 120.15E; key = phase at that local time.",
"times": TIMES,
"responses": {
key: compute_birth_sky(SAMPLE["date"], time, SAMPLE["tz"], SAMPLE["lat"], SAMPLE["lon"])
for key, time in TIMES.items()
},
}
GOLDEN.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(f"wrote {GOLDEN}")
if __name__ == "__main__":
main()
+7
View File
@@ -3412,6 +3412,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
except ephemeris.EphemerisEventsError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/birth_sky':
birth_sky = _load_local_module('birth_sky')
try:
result = birth_sky.compute_birth_sky_from_request(body)
except birth_sky.BirthSkyError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/declared_window_chart':
result = self._compute_declared_window_chart(body)
self._json(result)
+2
View File
@@ -85,6 +85,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_qizheng_api_productization.py",
"tests/test_readonly_chart_endpoints.py",
"tests/test_ephemeris_events.py",
# Birth-sky cover: real alt/az of planets and bright stars (TASK-birth-sky-cover).
"tests/test_birth_sky.py",
# Fail-fast heavy-compute concurrency gate (429 + Retry-After, health ungated).
"tests/test_api_heavy_compute_gate.py",
# Upstream-sync acceptance regressions must fail the automatic staging gate.