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:
co-authored by
Claude Opus 5.5
parent
d670521f47
commit
d277cbd8ad
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user