diff --git a/CHANGELOG.md b/CHANGELOG.md index 4445bf65..3c89b7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-28 — 星盘页新增「那一刻的天空」(待验收) + +- 星盘页顶栏右侧多一个按钮「那一刻的天空」:出生地、出生那一刻头顶的真实天空,画成一张 3:4 的图。天顶在圆心、东在左,地平线以上的行星和亮星(北斗、仙后、猎户连线)照实画。 +- 图上只有一句描述天空的话(四句之一,按当时是白天、清晨、傍晚还是夜里选),加一行「太阳在东,月亮在脚下,火星正悬在东南」这样的事实句;不讲运势、不贴标签,不印姓名、日期、时刻、经纬度和地名。出生时间没采用校正时,多一行「按你填写的时间」。 +- 「保存图片」:iPhone 弹系统分享(可「存储图像」),其他浏览器直接下载 `jyotisha-sky.png`;手机长按预览图也能存。家人的盘也能生成。不扣点、不调模型。 +- Skill 版本不 bump(Skill 文本未改)。不改数据库。 + ## 2026-09-28 — 标题改用宋体,正文仍是黑体(待验收) - 全站标题(品牌字、页面与弹窗标题、首页问候、回答里的小标题、登录页大标题、校正各卡标题、报告封面与章节标题)改用宋体;聊天正文、按钮、表格、输入框、标签和后台不变。 diff --git a/CONTEXT.md b/CONTEXT.md index 6f5174d8..b4a02c2d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,3 +79,7 @@ _避免使用_:宫位盘、等宫盘 **行运盘**: 某一天(星盘页是今天)中午出生地时区的行星位置,放进本命盘的宫位(本命上升为第 1 宫),岁差与交点和本命相同。 _避免使用_:流年盘、过运盘(泛指) + +**那一刻的天空**: +出生地、出生那一刻头顶的真实天空画成的 3:4 封面(天顶在圆心、东在左,地平线以上的行星与亮星照实画)。图上只有描述天空的一句话,不印任何出生资料,也不讲运势。 +_避免使用_:出生星空图、星盘海报、人设卡 diff --git a/docs/tasks/PROGRESS-birth-sky-cover-20260928.md b/docs/tasks/PROGRESS-birth-sky-cover-20260928.md new file mode 100644 index 00000000..619a376e --- /dev/null +++ b/docs/tasks/PROGRESS-birth-sky-cover-20260928.md @@ -0,0 +1,7 @@ +# PROGRESS · 「那一刻的天空」封面(2026-09-28) + +任务书:`docs/tasks/TASK-birth-sky-cover-20260928.md`。产品授权直接执行(Claude 子代理)。 + +- 基线:`origin/staging` = `d670521f`(serif-headings 已合入) +- 分支 / 工作树:`codex/birth-sky-cover-20260928` / `.worktrees/birth-sky-cover-20260928`(本地提交,**未推送**) +- 状态:提交 1(T1–T4 + T7)已完成;T5、T6 见后续提交。完整数字在最后一个提交里补齐。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 858d150a..b4934833 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -248,7 +248,7 @@ | `TASK-consult-first-frame-and-pacing-20260928.md` | `PROGRESS-consult-first-frame-and-pacing-20260928.md` | **普通对话发出即有反馈、正文按打字节奏放出**:客户端分类前静默(BUG-976 副作用)+ 服务端分类后才开流 → 发出后一两秒空白;服务端 160 字截留 + 客户端 settle 一帧全放 → 追问轮正文一下全出。D1 首帧「收到,正在看你的问题…」、D2 先建流、D3 每帧上限 + 限时收尾、D4 160 字阈值不动 | 已验收并部署 staging(真机欠) | 分支 `codex/consult-first-frame-and-pacing-20260928`(未推送);进度 `PROGRESS-consult-first-frame-and-pacing-20260928.md`;BUG-1074 / BUG-1075 | | `TASK-chart-types-and-report-buttons-20260928.md` | `PROGRESS-chart-types-and-report-buttons-20260928.md`、`PROGRESS-ayanamsa-report-buttons-20260928.md` | **星盘盘型扩充 + 表格随盘换 + 七政隐藏 + 岁差 6 项 + 我的报告按钮配色**:分盘表一直显示 D1 数据(BUG-1076);盘型条 + 「星盘类型」面板共 23 种(加 月亮盘 / Bhava / 行运 / D40 / D45 / D60);Bhava 端点缺 MC 静默等宫(BUG-1077);报告深色主按钮黑字深红、禁用半透明(BUG-1078,只改报告范围) | 已验收、已部署 staging `6a4e312c`(真机欠) | `af27709d`…`4189750c`(子代理实现 + Claude 验收补丁);BUG-1076~1078;含迁移 `20260928010000_profile_ayanamsa_six` | | `TASK-site-button-contrast-20260928.md` | `PROGRESS-site-button-contrast-20260928.md` | **全站实心按钮配色与禁用态统一**:层外 `button { color: inherit }` 压掉 Tailwind 文字色(61 个 ` + +
+ {/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */} + {description} +
+ + + + ); +} diff --git a/frontend/src/components/birth-sky/birth-sky-entry.tsx b/frontend/src/components/birth-sky/birth-sky-entry.tsx new file mode 100644 index 00000000..4389739d --- /dev/null +++ b/frontend/src/components/birth-sky/birth-sky-entry.tsx @@ -0,0 +1,30 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import type { BirthSkyCover } from "@/lib/birth-sky/cover"; + +const BirthSkyDialog = dynamic( + () => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog), + { ssr: false }, +); + +export const BIRTH_SKY_ENTRY_LABEL = "那一刻的天空"; + +/** + * The chart header's one extra action. Rendered only when the cover is already + * painted; the dialog (and its CSS) is a lazy chunk opened on click. + */ +export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open ? setOpen(false)} /> : null} + + ); +} diff --git a/frontend/src/components/birth-sky/birth-sky.css b/frontend/src/components/birth-sky/birth-sky.css new file mode 100644 index 00000000..7c46879d --- /dev/null +++ b/frontend/src/components/birth-sky/birth-sky.css @@ -0,0 +1,83 @@ +/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928). Loaded with the lazy + dialog chunk only, never on first paint. Tokens only; the picture itself is + painted on canvas with the night palette. */ +.birth-sky-dialog { + position: fixed; + inset: 0; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: var(--color-canvas); + color: var(--color-ink); + overflow: hidden; +} +@supports (height: 1dvh) { + .birth-sky-dialog { height: 100dvh; } +} +.birth-sky-dialog::backdrop { background: var(--color-scrim); } +.birth-sky-panel { display: flex; flex-direction: column; height: 100%; } +.birth-sky-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + padding-top: max(var(--space-2), env(safe-area-inset-top)); + flex-shrink: 0; +} +.birth-sky-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 500; } +.birth-sky-frame { + flex: 1 1 auto; + min-height: 0; + display: grid; + place-items: center; + padding: 0 var(--space-4); +} +.birth-sky-image { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + aspect-ratio: 3 / 4; + border-radius: var(--radius-md); + -webkit-touch-callout: default; +} +.birth-sky-footer { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2); + padding: var(--space-4); + padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); + flex-shrink: 0; +} +.birth-sky-footer p { flex-basis: 100%; margin: 0; text-align: center; color: var(--color-danger); font-size: var(--type-body-sm); } +.birth-sky-dialog button { min-height: 44px; min-width: 44px; } +@media (min-width: 860px) { + .birth-sky-dialog { + inset: 0; + width: min(520px, 92vw); + height: auto; + max-height: 92vh; + margin: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-elevated); + } + .birth-sky-panel { height: auto; max-height: calc(92vh - 2px); } + .birth-sky-image { max-height: calc(92vh - 160px); } +} +/* First-chart reveal: one 600ms fade, no motion when the reader asked for none. */ +.birth-sky-dialog.is-reveal[open] { animation: birth-sky-reveal 600ms ease-out; } +.birth-sky-dialog.is-reveal[open]::backdrop { animation: birth-sky-reveal 600ms ease-out; } +@keyframes birth-sky-reveal { from { opacity: 0; } to { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + .birth-sky-dialog.is-reveal[open], + .birth-sky-dialog.is-reveal[open]::backdrop { animation: none; } +} +@media print { .birth-sky-dialog { display: none !important; } } diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index aeb6d2cb..c4ea1471 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -2,8 +2,11 @@ import { useState } from "react"; +import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; +import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover"; import { useChartPage } from "@/hooks/use-chart-page"; +import type { BirthSkyCover } from "@/lib/birth-sky/cover"; import { CHART_VIEW_TABS, type ChartViewFailureStatus, @@ -22,12 +25,16 @@ import { ChartWesternTab } from "./chart-western-tab"; export function ChartPageRoute() { const { view, pendingLayers, layerFailures, requestLayer } = useChartPage(); + const ok = view?.status === "ok" ? view : null; + // 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3). + const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null); return ( ); } @@ -66,12 +73,15 @@ export function ChartPageView({ layerFailures, onNeedLayer, initialTab = "vedic", + birthSky = null, }: { view: ChartViewResponse | null; pendingLayers?: ReadonlySet; layerFailures?: ReadonlyMap; onNeedLayer?: (layer: ChartViewLayer) => void; initialTab?: ChartViewTabId; + /** Painted cover for the header button; null until ready, and after any failure. */ + birthSky?: BirthSkyCover | null; }) { const [tab, setTab] = useState(initialTab); const [vargaId, setVargaId] = useState("D1"); @@ -98,6 +108,7 @@ export function ChartPageView({ : undefined} > {view != null && view.status !== "ok" ? (
diff --git a/frontend/src/hooks/use-birth-sky-cover.ts b/frontend/src/hooks/use-birth-sky-cover.ts new file mode 100644 index 00000000..8307fc2f --- /dev/null +++ b/frontend/src/hooks/use-birth-sky-cover.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover"; + +/** + * Chart page prefetch for 「那一刻的天空」 (TASK-birth-sky-cover-20260928 T3). + * + * Starts as soon as the chart is `ok`, keyed by subject + profile fingerprint so + * switching person (or editing birth data) fetches again. Returns the cover + * only once the PNG is painted; until then, and on any failure, null — the + * header shows no button, no placeholder, no error. + */ +export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCover | null { + const key = subjectId ? `${subjectId}:${fingerprint ?? ""}` : null; + const [cover, setCover] = useState(null); + + useEffect(() => { + if (!key || !subjectId) return; + const controller = new AbortController(); + let loaded: BirthSkyCover | null = null; + void loadBirthSkyCover(key, subjectId, controller.signal).then((next) => { + if (controller.signal.aborted) { + releaseBirthSkyCover(next); + return; + } + loaded = next; + if (next) setCover(next); + }); + return () => { + controller.abort(); + releaseBirthSkyCover(loaded); + }; + }, [key, subjectId]); + + return cover && cover.key === key ? cover : null; +} diff --git a/frontend/src/lib/birth-sky/contract.ts b/frontend/src/lib/birth-sky/contract.ts new file mode 100644 index 00000000..c79064ef --- /dev/null +++ b/frontend/src/lib/birth-sky/contract.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** + * 「那一刻的天空」 BFF contract (TASK-birth-sky-cover-20260928 T2). + * + * The response describes only the sky: altitudes, azimuths (degrees from + * north, eastward), star magnitudes. It never carries the birth date, time, + * coordinates or place — the server reads those from the owned profile row and + * keeps them. + */ + +export const BIRTH_SKY_PHASES = ["day", "twilight_morning", "twilight_evening", "night"] as const; +export type BirthSkyPhase = (typeof BIRTH_SKY_PHASES)[number]; + +export const BIRTH_SKY_PLANET_IDS = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"] as const; +export type BirthSkyPlanetId = (typeof BIRTH_SKY_PLANET_IDS)[number]; + +const altitude = z.number().min(-90).max(90); +const azimuth = z.number().min(0).max(360); + +export const birthSkyPlanetSchema = z.strictObject({ + id: z.enum(BIRTH_SKY_PLANET_IDS), + altitude, + azimuth, +}); + +export const birthSkyStarSchema = z.strictObject({ + hr: z.number().int().positive(), + altitude, + azimuth, + magnitude: z.number().min(-2).max(4), +}); + +export const birthSkyLineSchema = z.strictObject({ + id: z.string().min(1).max(40), + strokes: z.array(z.array(z.number().int().positive()).min(2).max(20)).min(1).max(8), +}); + +export const birthSkyOkSchema = z.strictObject({ + status: z.literal("ok"), + phase: z.enum(BIRTH_SKY_PHASES), + sunAltitude: altitude, + planets: z.array(birthSkyPlanetSchema).length(BIRTH_SKY_PLANET_IDS.length), + stars: z.array(birthSkyStarSchema).max(2000), + lines: z.array(birthSkyLineSchema).max(8), + /** `reported`: the birth time is the one the user typed, not an adopted one. */ + timeBasis: z.enum(["adopted", "reported"]), +}); + +export const BIRTH_SKY_FAILURES = [ + "unauthenticated", + "birth_profile_incomplete", + "not_found", + "unavailable", +] as const; + +export const birthSkyFailureSchema = z.strictObject({ + status: z.enum(BIRTH_SKY_FAILURES), +}); + +export const birthSkyResponseSchema = z.union([birthSkyOkSchema, birthSkyFailureSchema]); + +export type BirthSkyOk = z.infer; +export type BirthSkyFailure = z.infer; +export type BirthSkyResponse = z.infer; + +/** Raw `POST /api/birth_sky` payload from the Python engine (snake_case). */ +const engineSchema = z.object({ + phase: z.enum(BIRTH_SKY_PHASES), + sun_altitude: altitude, + planets: z.array(birthSkyPlanetSchema), + stars: z.array(birthSkyStarSchema), + lines: z.array(birthSkyLineSchema), +}); + +export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["timeBasis"]): BirthSkyOk | null { + const parsed = engineSchema.safeParse(payload); + if (!parsed.success) return null; + const candidate = { + status: "ok" as const, + phase: parsed.data.phase, + sunAltitude: parsed.data.sun_altitude, + planets: parsed.data.planets.map(({ id, altitude: alt, azimuth: az }) => ({ id, altitude: alt, azimuth: az })), + stars: parsed.data.stars.map(({ hr, altitude: alt, azimuth: az, magnitude }) => ({ hr, altitude: alt, azimuth: az, magnitude })), + lines: parsed.data.lines.map(({ id, strokes }) => ({ id, strokes })), + timeBasis, + }; + const checked = birthSkyOkSchema.safeParse(candidate); + return checked.success ? checked.data : null; +} diff --git a/frontend/src/lib/birth-sky/cover.ts b/frontend/src/lib/birth-sky/cover.ts new file mode 100644 index 00000000..56fdfa29 --- /dev/null +++ b/frontend/src/lib/birth-sky/cover.ts @@ -0,0 +1,43 @@ +import { birthSkyResponseSchema, type BirthSkyOk } from "./contract.ts"; + +/** + * A ready-to-show cover: the sky data plus the PNG already painted, so the + * button that opens it never has to wait (DESIGN §9). + */ +export type BirthSkyCover = Readonly<{ + key: string; + sky: BirthSkyOk; + blob: Blob; + url: string; +}>; + +export async function fetchBirthSky(subjectId: string, signal?: AbortSignal): Promise { + const query = new URLSearchParams({ subject: subjectId || "self" }); + const response = await fetch(`/api/birth-sky?${query.toString()}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + cache: "no-store", + signal, + }); + if (!response.ok) return null; + const parsed = birthSkyResponseSchema.safeParse(await response.json().catch(() => null)); + return parsed.success && parsed.data.status === "ok" ? parsed.data : null; +} + +/** Fetch the sky and paint it. Resolves null on any failure; the caller shows nothing. */ +export async function loadBirthSkyCover(key: string, subjectId: string, signal?: AbortSignal): Promise { + try { + const sky = await fetchBirthSky(subjectId, signal); + if (!sky || signal?.aborted) return null; + const { renderBirthSkyPng } = await import("./render.ts"); + const blob = await renderBirthSkyPng(sky, "night"); + if (signal?.aborted) return null; + return { key, sky, blob, url: URL.createObjectURL(blob) }; + } catch { + return null; + } +} + +export function releaseBirthSkyCover(cover: BirthSkyCover | null | undefined): void { + if (cover) URL.revokeObjectURL(cover.url); +} diff --git a/frontend/src/lib/birth-sky/draw.ts b/frontend/src/lib/birth-sky/draw.ts new file mode 100644 index 00000000..7f11430d --- /dev/null +++ b/frontend/src/lib/birth-sky/draw.ts @@ -0,0 +1,271 @@ +import type { BirthSkyOk } from "./contract.ts"; +import { + BIRTH_SKY_DISCLAIMER, + BIRTH_SKY_PLANET_NAMES, + BIRTH_SKY_TOP_LABEL, + birthSkyFactLine, + birthSkyFootnote, + birthSkyHeadline, +} from "./sentence.ts"; + +/** + * One pure Canvas 2D painter for the 3:4 「那一刻的天空」 cover. The preview + * ``, the saved PNG and the print cover all come from this function, so + * web fonts never get lost in an SVG-to-image round trip. + * + * Projection: zenith at the centre, r = (90 − altitude) / 90 × R; north up, + * **east on the left** (we look up at the sky, not down at a map). + */ + +export type BirthSkyPaletteName = "night" | "paper"; + +export type BirthSkyPalette = Readonly<{ + background: string; + ink: string; + inkSecondary: string; + inkTertiary: string; + rule: string; + star: string; + sun: string; +}>; + +/** Values are DESIGN.md tokens; no new brand colours. */ +export const BIRTH_SKY_PALETTES: Readonly> = { + // Dark theme: page floor, ink, secondary / tertiary ink, hairline, action. + night: { + background: "#1a1a19", + ink: "#f2f0ea", + inkSecondary: "#b3afa4", + inkTertiary: "#9c988e", + rule: "#3c3b37", + star: "#f2f0ea", + sun: "#d78064", + }, + // Report print sheet: --report-paper, ink, --report-rule, --report-accent. + paper: { + background: "#fbfaf7", + ink: "#1d1d1f", + inkSecondary: "#5f5f59", + inkTertiary: "#6a6963", + rule: "#d8d6cf", + star: "#1d1d1f", + sun: "#85432f", + }, +}; + +export const BIRTH_SKY_EXPORT_WIDTH = 1080; +export const BIRTH_SKY_EXPORT_HEIGHT = 1440; +export const BIRTH_SKY_SERIF_FAMILY = "\"Jyotisha Serif SC\""; +export const BIRTH_SKY_SANS_STACK = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Microsoft YaHei\", sans-serif"; + +/** The design grid: 600 × 800 units, scaled to the canvas. */ +const UNIT_WIDTH = 600; +const CENTER_X = 300; +const CENTER_Y = 400; +const RADIUS = 200; + +export type BirthSkyDrawOptions = Readonly<{ + palette: BirthSkyPaletteName | BirthSkyPalette; + width: number; + height: number; + /** Defaults to 「按你填写的时间」 when the birth time is not an adopted one. */ + footnote?: string | null; + /** False when the heading serif did not load in time: draw in the sans stack. */ + serif?: boolean; +}>; + +/** Subset of CanvasRenderingContext2D this painter touches (tests pass a recorder). */ +export type BirthSkyCanvas = Pick< + CanvasRenderingContext2D, + | "save" | "restore" | "scale" | "fillRect" | "beginPath" | "closePath" | "arc" | "moveTo" | "lineTo" + | "fill" | "stroke" | "clip" | "setLineDash" | "fillText" | "measureText" + | "fillStyle" | "strokeStyle" | "lineWidth" | "globalAlpha" | "font" | "textAlign" | "textBaseline" +>; + +export type SkyPoint = Readonly<{ x: number; y: number }>; + +/** Zenith-centred projection in design units: north up, east left. */ +export function skyPoint(altitude: number, azimuth: number): SkyPoint { + const r = ((90 - altitude) / 90) * RADIUS; + const a = (azimuth * Math.PI) / 180; + return { x: CENTER_X - r * Math.sin(a), y: CENTER_Y - r * Math.cos(a) }; +} + +export const BIRTH_SKY_CENTER: SkyPoint = { x: CENTER_X, y: CENTER_Y }; + +function resolvePalette(palette: BirthSkyDrawOptions["palette"]): BirthSkyPalette { + return typeof palette === "string" ? BIRTH_SKY_PALETTES[palette] : palette; +} + +function font(weight: number, size: number, serif: boolean): string { + return `${weight} ${size}px ${serif ? `${BIRTH_SKY_SERIF_FAMILY}, ${BIRTH_SKY_SANS_STACK}` : BIRTH_SKY_SANS_STACK}`; +} + +function starRadius(magnitude: number): number { + return Math.max(0.7, 2.4 - magnitude * 0.55); +} + +type Box = { x0: number; y0: number; x1: number; y1: number }; + +function overlaps(a: Box, b: Box): boolean { + return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; +} + +export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: BirthSkyDrawOptions): void { + const palette = resolvePalette(options.palette); + const serif = options.serif ?? true; + const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote; + const unit = options.width / UNIT_WIDTH; + + ctx.save(); + ctx.fillStyle = palette.background; + ctx.fillRect(0, 0, options.width, options.height); + ctx.scale(unit, unit); + ctx.textBaseline = "alphabetic"; + + // Sky disc, clipped so nothing below the horizon leaks. + ctx.save(); + ctx.beginPath(); + ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2); + ctx.clip(); + + ctx.strokeStyle = palette.star; + ctx.lineWidth = 0.5; + ctx.globalAlpha = 0.22; + ctx.setLineDash([1, 4]); + for (const ring of [30, 60]) { + ctx.beginPath(); + ctx.arc(CENTER_X, CENTER_Y, ((90 - ring) / 90) * RADIUS, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.setLineDash([]); + + const visible = new Map(); + ctx.fillStyle = palette.star; + ctx.globalAlpha = 0.85; + for (const star of sky.stars) { + if (star.altitude <= 0) continue; + const point = skyPoint(star.altitude, star.azimuth); + visible.set(star.hr, point); + ctx.beginPath(); + ctx.arc(point.x, point.y, starRadius(star.magnitude), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.strokeStyle = palette.star; + ctx.lineWidth = 0.6; + ctx.globalAlpha = 0.4; + for (const line of sky.lines) { + for (const stroke of line.strokes) { + for (let index = 1; index < stroke.length; index += 1) { + const from = visible.get(stroke[index - 1]); + const to = visible.get(stroke[index]); + if (!from || !to) continue; + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + } + } + } + + const planets = sky.planets.filter((planet) => planet.altitude > 0); + for (const planet of planets) { + const point = skyPoint(planet.altitude, planet.azimuth); + if (planet.id === "sun") { + ctx.fillStyle = palette.sun; + ctx.globalAlpha = 0.18; + ctx.beginPath(); + ctx.arc(point.x, point.y, 16, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + ctx.strokeStyle = palette.sun; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.arc(point.x, point.y, 6, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(point.x, point.y, 1.6, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.fillStyle = palette.star; + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.arc(point.x, point.y, 3.2, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + + // Planet names sit outside the clip so a body near the horizon keeps its label. + ctx.font = font(400, 11, false); + const labels: Box[] = []; + for (const planet of planets) { + const point = skyPoint(planet.altitude, planet.azimuth); + const name = BIRTH_SKY_PLANET_NAMES[planet.id]; + const width = 24; + const candidates = planet.id === "sun" + ? [{ dx: 20, dy: 4, align: "left" as const }, { dx: -20, dy: 4, align: "right" as const }, { dx: 0, dy: -22, align: "center" as const }, { dx: 0, dy: 30, align: "center" as const }] + : [{ dx: 8, dy: -7, align: "left" as const }, { dx: 8, dy: 15, align: "left" as const }, { dx: -8, dy: -7, align: "right" as const }, { dx: -8, dy: 15, align: "right" as const }]; + const box = (choice: (typeof candidates)[number]): Box => { + const x0 = choice.align === "left" ? point.x + choice.dx : choice.align === "right" ? point.x + choice.dx - width : point.x - width / 2; + return { x0, y0: point.y + choice.dy - 11, x1: x0 + width, y1: point.y + choice.dy + 2 }; + }; + const choice = candidates.find((item) => !labels.some((taken) => overlaps(box(item), taken))) ?? candidates[0]; + labels.push(box(choice)); + ctx.fillStyle = planet.id === "sun" ? palette.sun : palette.ink; + ctx.globalAlpha = 0.9; + ctx.textAlign = choice.align; + ctx.fillText(name, point.x + choice.dx, point.y + choice.dy); + } + + ctx.strokeStyle = palette.star; + ctx.lineWidth = 0.9; + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = palette.inkSecondary; + ctx.globalAlpha = 0.9; + ctx.textAlign = "center"; + ctx.font = font(400, 12, false); + for (const [text, dx, dy] of [["北", 0, -1], ["南", 0, 1], ["东", -1, 0], ["西", 1, 0]] as const) { + ctx.fillText(text, CENTER_X + dx * (RADIUS + 18), CENTER_Y + dy * (RADIUS + 18) + 4); + } + + // Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer. + ctx.textAlign = "left"; + ctx.fillStyle = palette.inkSecondary; + ctx.globalAlpha = 1; + ctx.font = font(600, 12, serif); + // Tracked by hand (canvas letterSpacing is not in every Safari we support). + let cursor = 48; + for (const glyph of BIRTH_SKY_TOP_LABEL) { + ctx.fillText(glyph, cursor, 64); + cursor += ctx.measureText(glyph).width + 2; + } + + ctx.fillStyle = palette.rule; + ctx.fillRect(48, 80, 504, 0.8); + ctx.fillRect(48, 650, 504, 0.8); + + ctx.fillStyle = palette.ink; + ctx.font = font(600, 30, serif); + ctx.fillText(birthSkyHeadline(sky), 48, 146, 504); + + ctx.font = font(600, 15, false); + ctx.fillText(birthSkyFactLine(sky), 48, 686, 504); + + if (footnote) { + ctx.fillStyle = palette.inkSecondary; + ctx.font = font(400, 12.5, false); + ctx.fillText(footnote, 48, 712, 504); + } + + ctx.fillStyle = palette.inkTertiary; + ctx.font = font(400, 11, false); + ctx.textAlign = "center"; + ctx.fillText(BIRTH_SKY_DISCLAIMER, 300, 764); + ctx.restore(); +} diff --git a/frontend/src/lib/birth-sky/export.ts b/frontend/src/lib/birth-sky/export.ts new file mode 100644 index 00000000..b9c9534c --- /dev/null +++ b/frontend/src/lib/birth-sky/export.ts @@ -0,0 +1,73 @@ +/** + * Saving the 「那一刻的天空」 PNG (TASK-birth-sky-cover-20260928 T4). + * + * The file name carries no name and no date. Where the browser can share files + * (iOS Safari: 「存储图像」 in the system sheet) we hand the file to + * `navigator.share`; everywhere else it is the same Blob + `a.download` pattern + * as `downloadMarkdownReport`. A dismissed share sheet is not an error. + */ + +export const BIRTH_SKY_FILENAME = "jyotisha-sky.png"; + +export type BirthSkySaveOutcome = "shared" | "downloaded" | "cancelled"; + +type ShareNavigator = { + canShare?: (data: ShareData) => boolean; + share?: (data: ShareData) => Promise; +}; + +export type BirthSkySaveEnvironment = { + readonly navigator?: ShareNavigator | null; + readonly makeFile?: (blob: Blob, name: string) => File; + readonly download?: (blob: Blob, name: string) => void; +}; + +export function canvasToPngBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("birth_sky_png_unavailable")); + }, "image/png"); + }); +} + +export function downloadBlob(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +function isShareCancel(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +export async function saveBirthSkyImage(blob: Blob, env: BirthSkySaveEnvironment = {}): Promise { + const nav = env.navigator === undefined + ? (typeof navigator === "undefined" ? null : navigator as ShareNavigator) + : env.navigator; + const makeFile = env.makeFile ?? ((data: Blob, name: string) => new File([data], name, { type: "image/png" })); + const download = env.download ?? downloadBlob; + const file = makeFile(blob, BIRTH_SKY_FILENAME); + let canShare = false; + try { + canShare = Boolean(nav?.share && nav.canShare?.({ files: [file] })); + } catch { + canShare = false; + } + if (canShare && nav?.share) { + try { + await nav.share({ files: [file] }); + return "shared"; + } catch (error) { + if (isShareCancel(error)) return "cancelled"; + // A share sheet that fails for any other reason still owes the reader the file. + } + } + download(blob, BIRTH_SKY_FILENAME); + return "downloaded"; +} diff --git a/frontend/src/lib/birth-sky/render.ts b/frontend/src/lib/birth-sky/render.ts new file mode 100644 index 00000000..46c8285b --- /dev/null +++ b/frontend/src/lib/birth-sky/render.ts @@ -0,0 +1,60 @@ +import type { BirthSkyOk } from "./contract.ts"; +import { + BIRTH_SKY_EXPORT_HEIGHT, + BIRTH_SKY_EXPORT_WIDTH, + BIRTH_SKY_SERIF_FAMILY, + drawBirthSky, + type BirthSkyCanvas, + type BirthSkyPaletteName, +} from "./draw.ts"; +import { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } from "./sentence.ts"; +import { canvasToPngBlob } from "./export.ts"; + +/** Decision 7: wait for the heading serif at most this long, then draw in sans. */ +export const BIRTH_SKY_FONT_WAIT_MS = 1_500; + +type FontLoader = { load: (font: string, text?: string) => Promise }; + +/** + * Resolves true once the slices that cover the headline and the top label are + * loaded, false on timeout or failure. Never rejects, never shows a waiting state. + */ +export async function waitForHeadingSerif( + text: string, + fonts: FontLoader | null | undefined = typeof document === "undefined" ? null : document.fonts, + timeoutMs = BIRTH_SKY_FONT_WAIT_MS, +): Promise { + if (!fonts) return false; + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + const load = fonts.load(`600 30px ${BIRTH_SKY_SERIF_FAMILY}`, text) + .then((faces) => Array.isArray(faces) ? faces.length > 0 : true) + .catch(() => false); + try { + return await Promise.race([load, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** Paint the cover on an off-screen canvas and return the PNG. Browser only. */ +export async function renderBirthSkyPng( + sky: BirthSkyOk, + palette: BirthSkyPaletteName = "night", +): Promise { + const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`); + const canvas = document.createElement("canvas"); + canvas.width = BIRTH_SKY_EXPORT_WIDTH; + canvas.height = BIRTH_SKY_EXPORT_HEIGHT; + const context = canvas.getContext("2d"); + if (!context) throw new Error("birth_sky_canvas_unavailable"); + drawBirthSky(context as BirthSkyCanvas, sky, { + palette, + width: BIRTH_SKY_EXPORT_WIDTH, + height: BIRTH_SKY_EXPORT_HEIGHT, + serif, + }); + return canvasToPngBlob(canvas); +} diff --git a/frontend/src/lib/birth-sky/sentence.ts b/frontend/src/lib/birth-sky/sentence.ts new file mode 100644 index 00000000..1e36fd37 --- /dev/null +++ b/frontend/src/lib/birth-sky/sentence.ts @@ -0,0 +1,60 @@ +import type { BirthSkyOk, BirthSkyPhase, BirthSkyPlanetId } from "./contract.ts"; + +/** + * The only sentences the cover may print (TASK-birth-sky-cover-20260928 T3). + * They describe the sky and nothing else: no dasha, no ascendant, no character. + * Deterministic — the same sky always gets the same words. + */ +export const BIRTH_SKY_HEADLINES: Readonly> = { + day: "星星都在,只是还看不见。", + twilight_morning: "天快亮了,星星正一颗颗退场。", + twilight_evening: "天刚暗下来,星星正一颗颗出来。", + night: "那一刻,头顶就是这片天。", +}; + +export const BIRTH_SKY_TOP_LABEL = "JYOTISHA · 那 一 刻"; +export const BIRTH_SKY_DISCLAIMER = "仅供自我探索,不替你做决定。"; +export const BIRTH_SKY_REPORTED_FOOTNOTE = "按你填写的时间"; + +export const BIRTH_SKY_PLANET_NAMES: Readonly> = { + sun: "太阳", + moon: "月亮", + mercury: "水星", + venus: "金星", + mars: "火星", + jupiter: "木星", + saturn: "土星", +}; + +const OCTANTS = ["北", "东北", "东", "东南", "南", "西南", "西", "西北"] as const; + +/** Eight-point compass name for an azimuth measured from north, eastward. */ +export function octantName(azimuth: number): string { + const normalized = ((azimuth % 360) + 360) % 360; + return OCTANTS[Math.round(normalized / 45) % 8]; +} + +export function birthSkyHeadline(sky: Pick): string { + return BIRTH_SKY_HEADLINES[sky.phase]; +} + +/** + * 「太阳在东,月亮在脚下,火星正悬在东南」: the Sun only by day, then the Moon + * (above or below the horizon), then the highest other planet above it. + */ +export function birthSkyFactLine(sky: Pick): string { + const parts: string[] = []; + const sun = sky.planets.find((planet) => planet.id === "sun"); + if (sky.phase === "day" && sun) parts.push(`太阳在${octantName(sun.azimuth)}`); + const moon = sky.planets.find((planet) => planet.id === "moon"); + if (moon) parts.push(moon.altitude > 0 ? `月亮在${octantName(moon.azimuth)}` : "月亮在脚下"); + const highest = sky.planets + .filter((planet) => planet.id !== "sun" && planet.id !== "moon" && planet.altitude > 0) + .sort((left, right) => right.altitude - left.altitude)[0]; + if (highest) parts.push(`${BIRTH_SKY_PLANET_NAMES[highest.id]}正悬在${octantName(highest.azimuth)}`); + return parts.join(","); +} + +export function birthSkyFootnote(sky: Pick): string | null { + return sky.timeBasis === "reported" ? BIRTH_SKY_REPORTED_FOOTNOTE : null; +} diff --git a/frontend/src/lib/birth-sky/service-core.ts b/frontend/src/lib/birth-sky/service-core.ts new file mode 100644 index 00000000..74a017bd --- /dev/null +++ b/frontend/src/lib/birth-sky/service-core.ts @@ -0,0 +1,77 @@ +import { prepareChartViewProfile } from "../chart-view-profile-read.ts"; +import { ConsultationSubjectError, subjectIdFromQuery, type ResolvedSubjectBirth } from "../subject-birth.ts"; +import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts"; + +/** + * GET /api/birth-sky core (TASK-birth-sky-cover-20260928 T2). + * + * Birth truth is read on the server, from the owned profile row, exactly as the + * chart page reads it (`loadSubjectBirth` → `prepareChartViewProfile`). No + * client date or coordinate is accepted, so the endpoint cannot be used to draw + * an arbitrary sky. Never bills, never writes. + */ + +export type BirthSkyEngineBody = Readonly<{ + date: string; + time: string; + timezone_offset: number; + latitude: number; + longitude: number; +}>; + +export type BirthSkyDeps = Readonly<{ + userId: string | null; + subjectId: unknown; + loadSubject: (userId: string, subjectId: string) => Promise; + /** Returns the engine payload, or null on any engine failure. */ + postEngine: (body: BirthSkyEngineBody) => Promise; + prepareProfile?: typeof prepareChartViewProfile; + rateLimited?: boolean; +}>; + +export type BirthSkyResult = Readonly<{ httpStatus: number; body: BirthSkyResponse }>; + +const ADOPTED_STATUSES = new Set(["accepted", "confirmed"]); + +export async function assembleBirthSky(deps: BirthSkyDeps): Promise { + if (!deps.userId) return { httpStatus: 401, body: { status: "unauthenticated" } }; + if (deps.rateLimited) return { httpStatus: 429, body: { status: "unavailable" } }; + + const subjectId = subjectIdFromQuery(deps.subjectId); + let resolved: ResolvedSubjectBirth; + try { + resolved = await deps.loadSubject(deps.userId, subjectId); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + if (error.code === "subject_incomplete") return { httpStatus: 200, body: { status: "birth_profile_incomplete" } }; + if (error.code === "subject_not_found" || error.code === "subject_role_mismatch") { + return { httpStatus: 404, body: { status: "not_found" } }; + } + } + return { httpStatus: 503, body: { status: "unavailable" } }; + } + + const prepared = await (deps.prepareProfile ?? prepareChartViewProfile)({ row: resolved.row }); + if (!prepared.ok) { + return prepared.failure === "profile_incomplete" + ? { httpStatus: 200, body: { status: "birth_profile_incomplete" } } + : { httpStatus: 503, body: { status: "unavailable" } }; + } + const { profile } = prepared; + let payload: unknown = null; + try { + payload = await deps.postEngine({ + date: profile.date, + time: profile.time, + timezone_offset: profile.timezoneOffset, + latitude: profile.latitude, + longitude: profile.longitude, + }); + } catch { + payload = null; + } + const timeBasis = ADOPTED_STATUSES.has(profile.birthTimeStatus ?? "") ? "adopted" : "reported"; + const sky = payload == null ? null : birthSkyFromEngine(payload, timeBasis); + if (!sky) return { httpStatus: 503, body: { status: "unavailable" } }; + return { httpStatus: 200, body: sky }; +} diff --git a/frontend/src/lib/birth-sky/service.ts b/frontend/src/lib/birth-sky/service.ts new file mode 100644 index 00000000..2ca3448e --- /dev/null +++ b/frontend/src/lib/birth-sky/service.ts @@ -0,0 +1,39 @@ +import "server-only"; + +import { consumeRequestRateLimit } from "../request-rate-limit.ts"; +import { createServerSupabaseClient } from "../supabase/server.ts"; +import { loadSubjectBirth } from "../subject-birth.ts"; +import { assembleBirthSky, type BirthSkyEngineBody, type BirthSkyResult } from "./service-core.ts"; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +export const BIRTH_SKY_ENGINE_TIMEOUT_MS = 10_000; + +async function postBirthSky(body: BirthSkyEngineBody): Promise { + const response = await fetch(`${jyotishApiBase}/api/birth_sky`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + signal: AbortSignal.timeout(BIRTH_SKY_ENGINE_TIMEOUT_MS), + }); + if (!response.ok) { + console.warn("birth_sky_engine_failed", { httpStatus: response.status }); + return null; + } + return await response.json() as unknown; +} + +export async function loadBirthSky(input: { subjectId: unknown }): Promise { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + const rateLimited = user + ? !consumeRequestRateLimit({ key: `birthSky:${user.id}`, limit: 20, windowMs: 60_000 }).ok + : false; + return assembleBirthSky({ + userId: user?.id ?? null, + subjectId: input.subjectId, + rateLimited, + loadSubject: (userId, subjectId) => loadSubjectBirth(supabase, userId, subjectId), + postEngine: postBirthSky, + }); +} diff --git a/frontend/tests/birth-sky-draw.test.ts b/frontend/tests/birth-sky-draw.test.ts new file mode 100644 index 00000000..41596d79 --- /dev/null +++ b/frontend/tests/birth-sky-draw.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BIRTH_SKY_CENTER, + BIRTH_SKY_EXPORT_HEIGHT, + BIRTH_SKY_EXPORT_WIDTH, + BIRTH_SKY_PALETTES, + drawBirthSky, + skyPoint, + type BirthSkyCanvas, +} from "../src/lib/birth-sky/draw.ts"; +import { birthSkyGolden, goldenSky } from "./birth-sky-test-support.ts"; + +type Call = { method: string; args: unknown[]; state: Record }; + +function recorder() { + const calls: Call[] = []; + const state: Record = {}; + const target = {} as Record; + const ctx = new Proxy(target, { + get(_target, key: string) { + if (key in state) return state[key]; + if (key === "measureText") return (text: string) => ({ width: text.length * 10 }); + return (...args: unknown[]) => { calls.push({ method: key, args, state: { ...state } }); }; + }, + set(_target, key: string, value: unknown) { + state[key] = value; + return true; + }, + }) as unknown as BirthSkyCanvas; + return { ctx, calls, texts: () => calls.filter((call) => call.method === "fillText").map((call) => String(call.args[0])) }; +} + +function draw(phase: Parameters[0], timeBasis: "adopted" | "reported", palette: "night" | "paper" = "night") { + const canvas = recorder(); + drawBirthSky(canvas.ctx, goldenSky(phase, timeBasis), { + palette, + width: BIRTH_SKY_EXPORT_WIDTH, + height: BIRTH_SKY_EXPORT_HEIGHT, + }); + return canvas; +} + +test("export size is 1080 x 1440 (3:4) and the background fills it", () => { + assert.equal(BIRTH_SKY_EXPORT_WIDTH, 1080); + assert.equal(BIRTH_SKY_EXPORT_HEIGHT, 1440); + const { calls } = draw("day", "adopted"); + const background = calls.find((call) => call.method === "fillRect"); + assert.deepEqual(background?.args, [0, 0, 1080, 1440]); + assert.equal(background?.state.fillStyle, BIRTH_SKY_PALETTES.night.background); +}); + +test("no drawn text carries a date, a time, coordinates, a place or a name", () => { + for (const phase of ["day", "twilight_morning", "twilight_evening", "night"] as const) { + const texts = draw(phase, "reported").texts(); + const joined = texts.join("\n"); + assert.ok(texts.length >= 8, joined); + assert.doesNotMatch(joined, /\d{4}|\d{1,2}:\d{2}|\d+\s*[°′']|\d\s*[NSEW]\b|北纬|东经|杭州|UTC|年|月\d|日\d/); + assert.doesNotMatch(joined, new RegExp(birthSkyGolden.times[phase])); + assert.doesNotMatch(joined, /示例|虚构|王芳/); + } + const canvas = draw("day", "reported"); + const texts = canvas.texts(); + const topLabel = canvas.calls.filter((call) => call.method === "fillText" && call.args[2] === 64).map((call) => call.args[0]).join(""); + assert.equal(topLabel, "JYOTISHA · 那 一 刻"); + assert.ok(texts.includes("星星都在,只是还看不见。")); + assert.ok(texts.includes("太阳在东,月亮在脚下,火星正悬在东南")); + assert.ok(texts.includes("仅供自我探索,不替你做决定。")); + for (const direction of ["北", "南", "东", "西"]) assert.ok(texts.includes(direction)); +}); + +test("「按你填写的时间」 appears for a reported time and not for an adopted one", () => { + assert.ok(draw("day", "reported").texts().includes("按你填写的时间")); + assert.ok(!draw("day", "adopted").texts().includes("按你填写的时间")); +}); + +test("east is on the left, north is up", () => { + const east = skyPoint(0, 90); + const west = skyPoint(0, 270); + const north = skyPoint(0, 0); + assert.ok(east.x < BIRTH_SKY_CENTER.x); + assert.ok(west.x > BIRTH_SKY_CENTER.x); + assert.ok(north.y < BIRTH_SKY_CENTER.y); + assert.deepEqual(skyPoint(90, 123), BIRTH_SKY_CENTER); + // The real Sun of the sample (azimuth ~84.7, east) is painted left of centre. + const sun = goldenSky("day").planets.find((planet) => planet.id === "sun")!; + const { calls } = draw("day", "adopted"); + const sunRing = calls.find((call) => call.method === "arc" && call.args[2] === 6); + assert.ok(sunRing, "the Sun ring is drawn"); + assert.ok(Number(sunRing.args[0]) < BIRTH_SKY_CENTER.x); + assert.equal(sunRing.args[0], skyPoint(sun.altitude, sun.azimuth).x); +}); + +test("bodies below the horizon are not drawn; the Moon of the sample is not named on the disc", () => { + const texts = draw("day", "adopted").texts(); + assert.ok(!texts.includes("月亮")); + assert.ok(!texts.includes("木星")); + assert.ok(texts.includes("土星")); + assert.ok(texts.includes("火星")); + assert.ok(!texts.some((text) => /罗睺|计都/.test(text))); +}); + +test("headline and top label use the heading serif unless it timed out", () => { + const headlineFont = (serif: boolean | undefined) => { + const canvas = recorder(); + drawBirthSky(canvas.ctx, goldenSky("night"), { palette: "night", width: 600, height: 800, ...(serif === undefined ? {} : { serif }) }); + const call = canvas.calls.find((item) => item.method === "fillText" && item.args[0] === "那一刻,头顶就是这片天。"); + const fact = canvas.calls.find((item) => item.method === "fillText" && String(item.args[0]).startsWith("月亮")); + return { headline: String(call?.state.font), fact: String(fact?.state.font) }; + }; + assert.match(headlineFont(undefined).headline, /^600 30px "Jyotisha Serif SC", /); + assert.doesNotMatch(headlineFont(undefined).fact, /Jyotisha Serif SC/); + assert.doesNotMatch(headlineFont(false).headline, /Jyotisha Serif SC/); +}); + +test("the paper palette paints ink stars on a light sheet", () => { + const { calls } = draw("night", "adopted", "paper"); + assert.equal(calls[0]?.method, "save"); + const background = calls.find((call) => call.method === "fillRect"); + assert.equal(background?.state.fillStyle, "#fbfaf7"); + const starFill = calls.find((call) => call.method === "fill"); + assert.equal(starFill?.state.fillStyle, BIRTH_SKY_PALETTES.paper.star); +}); + +test("asterism lines only join stars that are above the horizon", () => { + const sky = goldenSky("night"); + const visible = new Set(sky.stars.map((star) => star.hr)); + const { calls } = draw("night", "adopted"); + const segments = calls.filter((call) => call.method === "lineTo").length; + let expected = 0; + for (const line of sky.lines) { + for (const stroke of line.strokes) { + for (let index = 1; index < stroke.length; index += 1) { + if (visible.has(stroke[index - 1]) && visible.has(stroke[index])) expected += 1; + } + } + } + assert.ok(expected > 0, "the night sample shows at least one asterism segment"); + assert.equal(segments, expected); +}); diff --git a/frontend/tests/birth-sky-export.test.ts b/frontend/tests/birth-sky-export.test.ts new file mode 100644 index 00000000..22237d16 --- /dev/null +++ b/frontend/tests/birth-sky-export.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { BIRTH_SKY_FILENAME, saveBirthSkyImage } from "../src/lib/birth-sky/export.ts"; +import { waitForHeadingSerif } from "../src/lib/birth-sky/render.ts"; + +const exportSource = readFileSync(new URL("../src/lib/birth-sky/export.ts", import.meta.url), "utf8"); + +function environment(share: "ok" | "cancel" | "fail" | "unsupported") { + const downloads: string[] = []; + const shared: ShareData[] = []; + const nav = share === "unsupported" + ? { canShare: () => false, share: async () => { throw new Error("must not share"); } } + : { + canShare: (data: ShareData) => Array.isArray(data.files) && data.files.length === 1, + share: async (data: ShareData) => { + shared.push(data); + if (share === "cancel") throw Object.assign(new Error("dismissed"), { name: "AbortError" }); + if (share === "fail") throw Object.assign(new Error("denied"), { name: "NotAllowedError" }); + }, + }; + return { + downloads, + shared, + env: { + navigator: nav, + makeFile: (blob: Blob, name: string) => new File([blob], name, { type: "image/png" }), + download: (_blob: Blob, name: string) => { downloads.push(name); }, + }, + }; +} + +const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }); + +test("the file name carries no name and no date", () => { + assert.equal(BIRTH_SKY_FILENAME, "jyotisha-sky.png"); + assert.match(exportSource, /toBlob\([\s\S]*"image\/png"\)/); +}); + +test("share-capable browsers get the system share sheet with the PNG file", async () => { + const { env, downloads, shared } = environment("ok"); + assert.equal(await saveBirthSkyImage(png, env), "shared"); + assert.equal(shared.length, 1); + const file = shared[0]!.files![0]!; + assert.equal(file.name, "jyotisha-sky.png"); + assert.equal(file.type, "image/png"); + assert.deepEqual(downloads, []); +}); + +test("without file sharing it downloads through Blob + a.download", async () => { + const { env, downloads } = environment("unsupported"); + assert.equal(await saveBirthSkyImage(png, env), "downloaded"); + assert.deepEqual(downloads, ["jyotisha-sky.png"]); + const bare = environment("unsupported"); + assert.equal(await saveBirthSkyImage(png, { ...bare.env, navigator: null }), "downloaded"); + assert.match(exportSource, /anchor\.download = name/); + assert.match(exportSource, /URL\.revokeObjectURL/); +}); + +test("a dismissed share sheet is not an error and does not fall back to a download", async () => { + const { env, downloads } = environment("cancel"); + assert.equal(await saveBirthSkyImage(png, env), "cancelled"); + assert.deepEqual(downloads, []); + const failing = environment("fail"); + assert.equal(await saveBirthSkyImage(png, failing.env), "downloaded"); + assert.deepEqual(failing.downloads, ["jyotisha-sky.png"]); +}); + +test("the heading serif wait gives up after its budget instead of hanging", async () => { + const never = { load: () => new Promise(() => {}) }; + const started = Date.now(); + assert.equal(await waitForHeadingSerif("那一刻", never, 30), false); + assert.ok(Date.now() - started < 1_000); + assert.equal(await waitForHeadingSerif("那一刻", { load: async () => [{}] }, 30), true); + assert.equal(await waitForHeadingSerif("那一刻", { load: async () => [] }, 30), false); + assert.equal(await waitForHeadingSerif("那一刻", { load: async () => { throw new Error("x"); } }, 30), false); + assert.equal(await waitForHeadingSerif("那一刻", null, 30), false); +}); diff --git a/frontend/tests/birth-sky-route.test.ts b/frontend/tests/birth-sky-route.test.ts new file mode 100644 index 00000000..ebb4b7e5 --- /dev/null +++ b/frontend/tests/birth-sky-route.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { birthSkyResponseSchema } from "../src/lib/birth-sky/contract.ts"; +import { assembleBirthSky, type BirthSkyDeps, type BirthSkyEngineBody } from "../src/lib/birth-sky/service-core.ts"; +import { ConsultationSubjectError, resolveSubjectBirth } from "../src/lib/subject-birth.ts"; +import { birthSkyGolden } from "./birth-sky-test-support.ts"; + +const routeSource = readFileSync(new URL("../src/app/api/birth-sky/route.ts", import.meta.url), "utf8"); +const serviceSource = readFileSync(new URL("../src/lib/birth-sky/service.ts", import.meta.url), "utf8"); +const coreSource = readFileSync(new URL("../src/lib/birth-sky/service-core.ts", import.meta.url), "utf8"); + +const otherId = "22222222-2222-4222-8222-222222222222"; + +// Fictional sample from the brief; never a real person. +const selfRow = { + name: "虚构甲", + birth_date: "1994-05-18", + reported_birth_time: "07:40:00", + birth_time_status: "reported", + latitude: 30.27, + longitude: 120.15, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + birth_place_label: "虚构地点", + ayanamsa: "lahiri", +}; + +function deps(overrides: Partial = {}) { + const engineCalls: BirthSkyEngineBody[] = []; + const base: BirthSkyDeps = { + userId: "user-1", + subjectId: "self", + loadSubject: (userId, subjectId) => resolveSubjectBirth({ + userId, + subjectId, + loadSelfRow: async () => selfRow, + loadOtherRow: async (id) => ({ ...selfRow, id, user_id: "someone-else", role: "other" }), + }), + postEngine: async (body) => { + engineCalls.push(body); + return birthSkyGolden.responses.day; + }, + }; + return { deps: { ...base, ...overrides }, engineCalls }; +} + +test("signed-out requests are 401 and never reach the engine", async () => { + const { deps: input, engineCalls } = deps({ userId: null }); + const result = await assembleBirthSky(input); + assert.equal(result.httpStatus, 401); + assert.deepEqual(result.body, { status: "unauthenticated" }); + assert.equal(engineCalls.length, 0); +}); + +test("someone else's subject is 404 and never reaches the engine", async () => { + const { deps: input, engineCalls } = deps({ subjectId: otherId }); + const result = await assembleBirthSky(input); + assert.equal(result.httpStatus, 404); + assert.deepEqual(result.body, { status: "not_found" }); + assert.equal(engineCalls.length, 0); +}); + +test("an incomplete profile says so, so the page shows no entry", async () => { + const { deps: incompleteSelf } = deps({ + loadSubject: (userId, subjectId) => resolveSubjectBirth({ + userId, + subjectId, + loadSelfRow: async () => ({ ...selfRow, latitude: null, longitude: null }), + loadOtherRow: async () => null, + }), + }); + const self = await assembleBirthSky(incompleteSelf); + assert.equal(self.httpStatus, 200); + assert.deepEqual(self.body, { status: "birth_profile_incomplete" }); + + const { deps: incompleteOther, engineCalls } = deps({ + loadSubject: async () => { throw new ConsultationSubjectError("subject_incomplete"); }, + }); + const other = await assembleBirthSky(incompleteOther); + assert.deepEqual(other.body, { status: "birth_profile_incomplete" }); + assert.equal(engineCalls.length, 0); +}); + +test("a complete profile returns the contract and leaks no birth data", async () => { + const { deps: input, engineCalls } = deps(); + const result = await assembleBirthSky(input); + assert.equal(result.httpStatus, 200); + assert.ok(birthSkyResponseSchema.safeParse(result.body).success); + assert.equal(result.body.status, "ok"); + if (result.body.status !== "ok") return; + assert.equal(result.body.phase, "day"); + assert.equal(result.body.timeBasis, "reported"); + assert.deepEqual(engineCalls, [{ date: "1994-05-18", time: "07:40", timezone_offset: 8, latitude: 30.27, longitude: 120.15 }]); + const json = JSON.stringify(result.body); + for (const key of ["latitude", "longitude", "date", "time", "timezone", "birth_place_label", "placeLabel", "name"]) { + assert.doesNotMatch(json, new RegExp(`"${key}"`), key); + } + for (const value of ["1994", "07:40", "30.27", "120.15", "虚构", "Asia/Shanghai"]) { + assert.ok(!json.includes(value), value); + } +}); + +test("an adopted birth time is labelled adopted; engine failure shows nothing", async () => { + const { deps: adopted } = deps({ + loadSubject: (userId, subjectId) => resolveSubjectBirth({ + userId, + subjectId, + loadSelfRow: async () => ({ ...selfRow, birth_time_status: "confirmed", active_birth_time: "07:40:00" }), + loadOtherRow: async () => null, + }), + }); + const ok = await assembleBirthSky(adopted); + assert.equal(ok.body.status === "ok" ? ok.body.timeBasis : null, "adopted"); + + const { deps: failing } = deps({ postEngine: async () => { throw new Error("down"); } }); + const failed = await assembleBirthSky(failing); + assert.equal(failed.httpStatus, 503); + assert.deepEqual(failed.body, { status: "unavailable" }); + + const { deps: malformed } = deps({ postEngine: async () => ({ phase: "day" }) }); + assert.deepEqual((await assembleBirthSky(malformed)).body, { status: "unavailable" }); +}); + +test("the route reads only ?subject= and loads birth truth on the server; no billing", () => { + assert.match(routeSource, /export async function GET/); + assert.match(routeSource, /searchParams\.get\("subject"\)/); + assert.doesNotMatch(routeSource, /searchParams\.get\("(lat|lon|latitude|longitude|date|time|tz)/); + assert.doesNotMatch(routeSource, /request\.json\(\)/); + assert.match(serviceSource, /loadSubjectBirth/); + assert.match(serviceSource, /createServerSupabaseClient/); + assert.match(coreSource, /prepareChartViewProfile/); + for (const source of [routeSource, serviceSource, coreSource]) { + assert.doesNotMatch(source, /consultation-billing|authorizeUsage|completeUsage|@\/mastra|\.insert\(|\.update\(|\.upsert\(/); + } +}); diff --git a/frontend/tests/birth-sky-sentence.test.ts b/frontend/tests/birth-sky-sentence.test.ts new file mode 100644 index 00000000..25c1062a --- /dev/null +++ b/frontend/tests/birth-sky-sentence.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BIRTH_SKY_HEADLINES, + birthSkyFactLine, + birthSkyFootnote, + birthSkyHeadline, + octantName, +} from "../src/lib/birth-sky/sentence.ts"; +import type { BirthSkyOk } from "../src/lib/birth-sky/contract.ts"; +import { birthSkyGolden, goldenSky } from "./birth-sky-test-support.ts"; + +test("the fictional sample (07:40) reads exactly as the brief", () => { + const sky = goldenSky("day"); + assert.equal(birthSkyGolden.times.day, "07:40"); + assert.equal(birthSkyHeadline(sky), "星星都在,只是还看不见。"); + assert.equal(birthSkyFactLine(sky), "太阳在东,月亮在脚下,火星正悬在东南"); +}); + +test("each phase takes its one headline from the four-line table", () => { + assert.deepEqual(Object.values(BIRTH_SKY_HEADLINES), [ + "星星都在,只是还看不见。", + "天快亮了,星星正一颗颗退场。", + "天刚暗下来,星星正一颗颗出来。", + "那一刻,头顶就是这片天。", + ]); + assert.equal(birthSkyHeadline(goldenSky("day")), BIRTH_SKY_HEADLINES.day); + assert.equal(birthSkyHeadline(goldenSky("twilight_morning")), "天快亮了,星星正一颗颗退场。"); + assert.equal(birthSkyHeadline(goldenSky("twilight_evening")), "天刚暗下来,星星正一颗颗出来。"); + assert.equal(birthSkyHeadline(goldenSky("night")), "那一刻,头顶就是这片天。"); +}); + +test("outside daytime the fact line never names the Sun", () => { + for (const phase of ["twilight_morning", "twilight_evening", "night"] as const) { + const line = birthSkyFactLine(goldenSky(phase)); + assert.doesNotMatch(line, /太阳/); + assert.match(line, /^月亮在(脚下|北|东北|东|东南|南|西南|西|西北)/); + assert.ok(line.split(",").length <= 2, line); + } +}); + +test("fact line combinations: moon above, no visible planet, highest planet wins", () => { + const base = goldenSky("night"); + const planets = (overrides: Record): BirthSkyOk["planets"] => base.planets.map((planet) => { + const next = overrides[planet.id]; + return next ? { ...planet, altitude: next[0], azimuth: next[1] } : { ...planet, altitude: -10 }; + }); + assert.equal( + birthSkyFactLine({ phase: "night", planets: planets({ moon: [40, 225], jupiter: [20, 10], venus: [35, 300] }) }), + "月亮在西南,金星正悬在西北", + ); + assert.equal(birthSkyFactLine({ phase: "night", planets: planets({}) }), "月亮在脚下"); + assert.equal( + birthSkyFactLine({ phase: "day", planets: planets({ sun: [10, 225], saturn: [5, 170] }) }), + "太阳在西南,月亮在脚下,土星正悬在南", + ); +}); + +test("eight-point compass rounds to the nearest 45 degrees, north at both ends", () => { + assert.equal(octantName(0), "北"); + assert.equal(octantName(22.4), "北"); + assert.equal(octantName(22.6), "东北"); + assert.equal(octantName(84.69), "东"); + assert.equal(octantName(117.24), "东南"); + assert.equal(octantName(191.14), "南"); + assert.equal(octantName(350), "北"); +}); + +test("the reported-time footnote appears only when the time was not adopted", () => { + assert.equal(birthSkyFootnote({ timeBasis: "reported" }), "按你填写的时间"); + assert.equal(birthSkyFootnote({ timeBasis: "adopted" }), null); +}); diff --git a/frontend/tests/birth-sky-test-support.ts b/frontend/tests/birth-sky-test-support.ts new file mode 100644 index 00000000..8d71da22 --- /dev/null +++ b/frontend/tests/birth-sky-test-support.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; + +import { birthSkyFromEngine, type BirthSkyOk, type BirthSkyPhase } from "../src/lib/birth-sky/contract.ts"; + +/** Real engine responses (scripts/generate_birth_sky_golden.py), fictional sample birth. */ +export const birthSkyGolden = JSON.parse( + readFileSync(new URL("./fixtures/birth-sky-golden.json", import.meta.url), "utf8"), +) as { times: Record; responses: Record> }; + +export function goldenSky(phase: BirthSkyPhase, timeBasis: BirthSkyOk["timeBasis"] = "reported"): BirthSkyOk { + const sky = birthSkyFromEngine(birthSkyGolden.responses[phase], timeBasis); + if (!sky) throw new Error(`golden ${phase} does not satisfy the contract`); + return sky; +} diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 291cf304..8e79f762 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -18,6 +18,7 @@ import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts"; import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; +import { goldenSky } from "./birth-sky-test-support.ts"; Object.assign(globalThis, { React }); @@ -449,3 +450,46 @@ test("the (app) layout mounts one sidebar for home and the four secondary routes assert.match(provider, /readBootAccount\(\)/); assert.match(accountRead, /fetch\("\/api\/account"/); }); + +// 「那一刻的天空」 header entry (TASK-birth-sky-cover-20260928 T3). +test("the birth-sky button appears only once the chart is ok and the cover is painted", async () => { + const view = await okView(); + const cover = { + key: "self:fp", + sky: goldenSky("day"), + blob: new Blob([], { type: "image/png" }), + url: "blob:birth-sky", + }; + const ready = withSidebarProvider(React.createElement(ChartPageView, { view, birthSky: cover })); + assert.match(ready, /chat-header-actions/); + assert.match(ready, />那一刻的天空 { + const chartView = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8"); + const hook = readFileSync(new URL("../src/hooks/use-birth-sky-cover.ts", import.meta.url), "utf8"); + const entry = readFileSync(new URL("../src/components/birth-sky/birth-sky-entry.tsx", import.meta.url), "utf8"); + const cover = readFileSync(new URL("../src/lib/birth-sky/cover.ts", import.meta.url), "utf8"); + assert.match(chartView, /useBirthSkyCover\(ok \? ok\.subjectId \?\? "self" : null, ok\?\.profileFingerprint \?\? null\)/); + assert.match(hook, /\$\{subjectId\}:\$\{fingerprint \?\? ""\}/); + assert.match(hook, /controller\.abort\(\)/); + assert.match(entry, /dynamic\(\s*\(\) => import\("\.\/birth-sky-dialog"\)/); + assert.match(cover, /await import\("\.\/render\.ts"\)/); + assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/); +}); diff --git a/frontend/tests/fixtures/birth-sky-golden.json b/frontend/tests/fixtures/birth-sky-golden.json new file mode 100644 index 00000000..cff07dd3 --- /dev/null +++ b/frontend/tests/fixtures/birth-sky-golden.json @@ -0,0 +1 @@ +{"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":{"day":"07:40","twilight_morning":"04:50","twilight_evening":"19:10","night":"00:00"},"responses":{"day":{"phase":"day","sun_altitude":31.71,"planets":[{"id":"sun","altitude":31.71,"azimuth":84.69},{"id":"moon","altitude":-40.71,"azimuth":40.36},{"id":"mercury","altitude":17.78,"azimuth":71.14},{"id":"venus","altitude":7.95,"azimuth":65.88},{"id":"mars","altitude":52.33,"azimuth":117.24},{"id":"jupiter","altitude":-44.88,"azimuth":282.73},{"id":"saturn","altitude":50.31,"azimuth":191.14}],"stars":[{"hr":15,"altitude":79.99,"azimuth":94.09,"magnitude":2.06},{"hr":21,"altitude":60.09,"azimuth":11.98,"magnitude":2.27},{"hr":25,"altitude":13.21,"azimuth":171.65,"magnitude":3.88},{"hr":39,"altitude":70.94,"azimuth":139.67,"magnitude":2.83},{"hr":74,"altitude":48.56,"azimuth":158.52,"magnitude":3.56},{"hr":99,"altitude":15.93,"azimuth":167.83,"magnitude":2.39},{"hr":100,"altitude":14.6,"azimuth":168.2,"magnitude":3.94},{"hr":153,"altitude":62.86,"azimuth":24.31,"magnitude":3.66},{"hr":165,"altitude":73.5,"azimuth":83.17,"magnitude":3.27},{"hr":168,"altitude":60.43,"azimuth":21.87,"magnitude":2.23},{"hr":188,"altitude":37.93,"azimuth":155.35,"magnitude":2.04},{"hr":219,"altitude":58.71,"azimuth":22.2,"magnitude":3.44},{"hr":264,"altitude":55.82,"azimuth":20.34,"magnitude":2.47},{"hr":269,"altitude":69.0,"azimuth":60.69,"magnitude":3.87},{"hr":322,"altitude":9.52,"azimuth":162.35,"magnitude":3.31},{"hr":334,"altitude":42.17,"azimuth":143.68,"magnitude":3.45},{"hr":337,"altitude":66.99,"azimuth":69.54,"magnitude":2.06},{"hr":338,"altitude":1.5,"azimuth":165.3,"magnitude":3.92},{"hr":402,"altitude":41.73,"azimuth":137.92,"magnitude":3.6},{"hr":403,"altitude":53.72,"azimuth":25.45,"magnitude":2.68},{"hr":424,"altitude":30.8,"azimuth":0.64,"magnitude":2.02},{"hr":429,"altitude":10.94,"azimuth":157.27,"magnitude":3.41},{"hr":437,"altitude":56.93,"azimuth":109.62,"magnitude":3.62},{"hr":440,"altitude":5.6,"azimuth":159.5,"magnitude":3.95},{"hr":464,"altitude":58.5,"azimuth":44.78,"magnitude":3.57},{"hr":509,"altitude":32.58,"azimuth":138.68,"magnitude":3.5},{"hr":539,"altitude":35.87,"azimuth":132.77,"magnitude":3.73},{"hr":542,"altitude":48.96,"azimuth":24.55,"magnitude":3.38},{"hr":544,"altitude":57.55,"azimuth":81.58,"magnitude":3.41},{"hr":553,"altitude":54.61,"azimuth":96.44,"magnitude":2.64},{"hr":566,"altitude":1.44,"azimuth":157.34,"magnitude":3.7},{"hr":580,"altitude":42.85,"azimuth":15.43,"magnitude":3.98},{"hr":585,"altitude":26.2,"azimuth":138.76,"magnitude":4.0},{"hr":603,"altitude":55.73,"azimuth":58.17,"magnitude":2.26},{"hr":617,"altitude":52.88,"azimuth":90.46,"magnitude":2.0},{"hr":622,"altitude":54.81,"azimuth":71.12,"magnitude":3.0},{"hr":681,"altitude":36.31,"azimuth":120.28,"magnitude":3.04},{"hr":804,"altitude":35.49,"azimuth":109.67,"magnitude":3.47},{"hr":834,"altitude":45.7,"azimuth":39.26,"magnitude":3.76},{"hr":838,"altitude":44.85,"azimuth":80.4,"magnitude":3.63},{"hr":854,"altitude":45.79,"azimuth":43.81,"magnitude":3.95},{"hr":874,"altitude":25.46,"azimuth":118.48,"magnitude":3.89},{"hr":897,"altitude":3.53,"azimuth":141.88,"magnitude":3.24},{"hr":911,"altitude":32.07,"azimuth":105.59,"magnitude":2.53},{"hr":915,"altitude":44.12,"azimuth":43.09,"magnitude":2.93},{"hr":921,"altitude":44.11,"azimuth":63.57,"magnitude":3.39},{"hr":936,"altitude":43.76,"azimuth":60.56,"magnitude":2.12},{"hr":941,"altitude":43.77,"azimuth":55.15,"magnitude":3.8},{"hr":963,"altitude":9.37,"azimuth":131.71,"magnitude":3.87},{"hr":1003,"altitude":12.9,"azimuth":125.06,"magnitude":3.69},{"hr":1017,"altitude":41.33,"azimuth":48.31,"magnitude":1.79},{"hr":1030,"altitude":29.9,"azimuth":97.26,"magnitude":3.6},{"hr":1038,"altitude":29.74,"azimuth":96.23,"magnitude":3.74},{"hr":1084,"altitude":18.02,"azimuth":112.98,"magnitude":3.73},{"hr":1122,"altitude":38.18,"azimuth":50.82,"magnitude":3.01},{"hr":1131,"altitude":34.97,"azimuth":69.74,"magnitude":3.83},{"hr":1135,"altitude":37.13,"azimuth":57.29,"magnitude":3.77},{"hr":1136,"altitude":15.78,"azimuth":111.68,"magnitude":3.54},{"hr":1142,"altitude":32.15,"azimuth":78.96,"magnitude":3.7},{"hr":1149,"altitude":32.04,"azimuth":78.59,"magnitude":3.87},{"hr":1165,"altitude":31.6,"azimuth":78.71,"magnitude":2.87},{"hr":1178,"altitude":31.22,"azimuth":78.6,"magnitude":3.63},{"hr":1203,"altitude":32.86,"azimuth":69.47,"magnitude":2.85},{"hr":1220,"altitude":34.29,"azimuth":59.82,"magnitude":2.89},{"hr":1231,"altitude":10.66,"azimuth":112.67,"magnitude":2.95},{"hr":1239,"altitude":23.84,"azimuth":89.11,"magnitude":3.47},{"hr":1251,"altitude":20.15,"azimuth":94.92,"magnitude":3.91},{"hr":1346,"altitude":21.16,"azimuth":83.79,"magnitude":3.65},{"hr":1373,"altitude":21.35,"azimuth":81.59,"magnitude":3.76},{"hr":1409,"altitude":20.86,"azimuth":79.37,"magnitude":3.53},{"hr":1411,"altitude":19.43,"azimuth":82.44,"magnitude":3.84},{"hr":1412,"altitude":19.37,"azimuth":82.52,"magnitude":3.4},{"hr":1457,"altitude":18.11,"azimuth":81.08,"magnitude":0.85},{"hr":1463,"altitude":8.25,"azimuth":98.82,"magnitude":3.93},{"hr":1481,"altitude":2.1,"azimuth":107.93,"magnitude":3.87},{"hr":1543,"altitude":10.56,"azimuth":88.07,"magnitude":3.19},{"hr":1552,"altitude":9.6,"azimuth":89.09,"magnitude":3.69},{"hr":1567,"altitude":7.35,"azimuth":91.48,"magnitude":3.72},{"hr":1577,"altitude":20.9,"azimuth":62.93,"magnitude":2.69},{"hr":1605,"altitude":23.92,"azimuth":51.82,"magnitude":2.99},{"hr":1612,"altitude":22.87,"azimuth":54.59,"magnitude":3.75},{"hr":1641,"altitude":22.22,"azimuth":54.14,"magnitude":3.17},{"hr":1666,"altitude":0.61,"azimuth":96.26,"magnitude":2.79},{"hr":1708,"altitude":22.24,"azimuth":48.62,"magnitude":0.08},{"hr":1790,"altitude":2.66,"azimuth":84.2,"magnitude":1.64},{"hr":1791,"altitude":13.27,"azimuth":64.41,"magnitude":1.65},{"hr":1879,"altitude":2.33,"azimuth":79.86,"magnitude":3.54},{"hr":1910,"altitude":7.43,"azimuth":69.82,"magnitude":3.0},{"hr":2012,"altitude":13.56,"azimuth":52.32,"magnitude":3.97},{"hr":2077,"altitude":19.2,"azimuth":37.61,"magnitude":3.72},{"hr":2088,"altitude":14.98,"azimuth":46.32,"magnitude":1.9},{"hr":2095,"altitude":11.21,"azimuth":53.26,"magnitude":2.62},{"hr":2216,"altitude":0.74,"azimuth":64.16,"magnitude":3.28},{"hr":2540,"altitude":0.42,"azimuth":50.01,"magnitude":3.6},{"hr":3323,"altitude":7.57,"azimuth":19.7,"magnitude":3.36},{"hr":3757,"altitude":5.95,"azimuth":12.26,"magnitude":3.67},{"hr":3888,"altitude":1.35,"azimuth":11.56,"magnitude":3.8},{"hr":4301,"altitude":2.13,"azimuth":2.24,"magnitude":1.79},{"hr":4434,"altitude":9.64,"azimuth":359.22,"magnitude":3.84},{"hr":4787,"altitude":10.91,"azimuth":353.86,"magnitude":3.87},{"hr":5291,"altitude":9.83,"azimuth":343.46,"magnitude":3.65},{"hr":5563,"altitude":19.9,"azimuth":346.78,"magnitude":2.08},{"hr":5735,"altitude":20.05,"azimuth":343.38,"magnitude":3.05},{"hr":5744,"altitude":12.34,"azimuth":332.64,"magnitude":3.29},{"hr":6092,"altitude":11.73,"azimuth":317.24,"magnitude":3.89},{"hr":6132,"altitude":19.83,"azimuth":330.63,"magnitude":2.74},{"hr":6148,"altitude":0.16,"azimuth":295.01,"magnitude":2.77},{"hr":6212,"altitude":7.61,"azimuth":302.3,"magnitude":2.81},{"hr":6220,"altitude":11.6,"azimuth":308.53,"magnitude":3.53},{"hr":6324,"altitude":10.79,"azimuth":299.65,"magnitude":3.92},{"hr":6396,"altitude":26.01,"azimuth":332.83,"magnitude":3.17},{"hr":6406,"altitude":5.53,"azimuth":283.46,"magnitude":3.48},{"hr":6410,"altitude":10.72,"azimuth":292.62,"magnitude":3.14},{"hr":6418,"altitude":16.23,"azimuth":303.56,"magnitude":3.16},{"hr":6536,"altitude":24.66,"azimuth":317.75,"magnitude":2.79},{"hr":6556,"altitude":8.91,"azimuth":279.4,"magnitude":2.08},{"hr":6588,"altitude":24.02,"azimuth":310.69,"magnitude":3.8},{"hr":6603,"altitude":6.8,"azimuth":271.34,"magnitude":2.77},{"hr":6623,"altitude":18.3,"azimuth":291.98,"magnitude":3.42},{"hr":6629,"altitude":6.82,"azimuth":269.16,"magnitude":3.75},{"hr":6688,"altitude":29.08,"azimuth":321.72,"magnitude":3.75},{"hr":6695,"altitude":23.95,"azimuth":300.51,"magnitude":3.86},{"hr":6698,"altitude":2.8,"azimuth":256.98,"magnitude":3.34},{"hr":6703,"altitude":21.2,"azimuth":292.36,"magnitude":3.7},{"hr":6705,"altitude":28.31,"azimuth":315.62,"magnitude":2.23},{"hr":6714,"altitude":9.68,"azimuth":267.74,"magnitude":3.97},{"hr":6771,"altitude":14.41,"azimuth":272.79,"magnitude":3.73},{"hr":6779,"altitude":22.96,"azimuth":290.96,"magnitude":3.83},{"hr":6869,"altitude":11.12,"azimuth":259.95,"magnitude":3.26},{"hr":6895,"altitude":23.47,"azimuth":282.4,"magnitude":3.84},{"hr":6913,"altitude":0.16,"azimuth":240.09,"magnitude":2.81},{"hr":6927,"altitude":33.12,"azimuth":339.95,"magnitude":3.57},{"hr":6973,"altitude":11.17,"azimuth":253.47,"magnitude":3.85},{"hr":7001,"altitude":32.04,"azimuth":299.36,"magnitude":0.03},{"hr":7039,"altitude":2.5,"azimuth":236.52,"magnitude":3.17},{"hr":7106,"altitude":33.09,"azimuth":292.3,"magnitude":3.45},{"hr":7121,"altitude":4.64,"azimuth":235.8,"magnitude":2.02},{"hr":7150,"altitude":8.22,"azimuth":239.62,"magnitude":3.51},{"hr":7178,"altitude":34.68,"azimuth":290.88,"magnitude":3.24},{"hr":7194,"altitude":3.73,"azimuth":231.96,"magnitude":2.6},{"hr":7217,"altitude":9.12,"azimuth":238.11,"magnitude":3.77},{"hr":7234,"altitude":5.84,"azimuth":233.1,"magnitude":3.32},{"hr":7235,"altitude":28.97,"azimuth":269.64,"magnitude":2.99},{"hr":7236,"altitude":19.44,"azimuth":251.9,"magnitude":3.44},{"hr":7264,"altitude":10.5,"azimuth":237.94,"magnitude":2.89},{"hr":7310,"altitude":38.18,"azimuth":334.61,"magnitude":3.07},{"hr":7328,"altitude":40.67,"azimuth":316.34,"magnitude":3.77},{"hr":7340,"altitude":14.67,"azimuth":238.68,"magnitude":3.93},{"hr":7348,"altitude":0.14,"azimuth":220.95,"magnitude":3.97},{"hr":7377,"altitude":27.89,"azimuth":256.23,"magnitude":3.36},{"hr":7417,"altitude":39.86,"azimuth":282.69,"magnitude":3.08},{"hr":7420,"altitude":42.66,"azimuth":314.28,"magnitude":3.79},{"hr":7525,"altitude":36.2,"azimuth":260.6,"magnitude":2.72},{"hr":7528,"altitude":45.4,"azimuth":305.21,"magnitude":2.87},{"hr":7536,"altitude":40.07,"azimuth":269.41,"magnitude":3.82},{"hr":7557,"altitude":36.27,"azimuth":258.04,"magnitude":0.77},{"hr":7570,"altitude":32.27,"azimuth":249.83,"magnitude":3.9},{"hr":7582,"altitude":40.38,"azimuth":339.1,"magnitude":3.83},{"hr":7602,"altitude":35.91,"azimuth":254.72,"magnitude":3.71},{"hr":7615,"altitude":46.81,"azimuth":290.5,"magnitude":3.89},{"hr":7635,"altitude":42.92,"azimuth":269.11,"magnitude":3.47},{"hr":7710,"altitude":34.91,"azimuth":244.66,"magnitude":3.23},{"hr":7735,"altitude":50.27,"azimuth":308.07,"magnitude":3.79},{"hr":7751,"altitude":50.44,"azimuth":309.65,"magnitude":3.98},{"hr":7754,"altitude":28.32,"azimuth":233.09,"magnitude":3.57},{"hr":7776,"altitude":27.22,"azimuth":230.74,"magnitude":3.08},{"hr":7796,"altitude":52.29,"azimuth":297.9,"magnitude":2.2},{"hr":7869,"altitude":3.98,"azimuth":206.65,"magnitude":3.11},{"hr":7882,"altitude":49.04,"azimuth":256.83,"magnitude":3.63},{"hr":7906,"altitude":50.12,"azimuth":258.23,"magnitude":3.77},{"hr":7924,"altitude":55.27,"azimuth":306.99,"magnitude":1.25},{"hr":7949,"altitude":56.88,"azimuth":286.79,"magnitude":2.46},{"hr":7950,"altitude":35.52,"azimuth":229.32,"magnitude":3.77},{"hr":7957,"altitude":49.46,"azimuth":332.6,"magnitude":3.43},{"hr":8028,"altitude":58.82,"azimuth":300.46,"magnitude":3.94},{"hr":8079,"altitude":59.65,"azimuth":306.32,"magnitude":3.72},{"hr":8115,"altitude":62.09,"azimuth":278.19,"magnitude":3.2},{"hr":8130,"altitude":62.6,"azimuth":295.07,"magnitude":3.72},{"hr":8131,"altitude":51.04,"azimuth":236.34,"magnitude":3.92},{"hr":8162,"altitude":52.05,"azimuth":337.31,"magnitude":2.44},{"hr":8204,"altitude":30.39,"azimuth":211.3,"magnitude":3.74},{"hr":8232,"altitude":45.35,"azimuth":221.29,"magnitude":2.91},{"hr":8238,"altitude":46.75,"azimuth":346.61,"magnitude":3.23},{"hr":8278,"altitude":36.95,"azimuth":211.24,"magnitude":3.68},{"hr":8308,"altitude":59.3,"azimuth":233.48,"magnitude":2.39},{"hr":8322,"altitude":38.18,"azimuth":209.68,"magnitude":2.87},{"hr":8353,"altitude":19.22,"azimuth":198.53,"magnitude":3.01},{"hr":8414,"altitude":54.34,"azimuth":214.37,"magnitude":2.96},{"hr":8425,"altitude":10.94,"azimuth":192.81,"magnitude":1.74},{"hr":8430,"altitude":72.59,"azimuth":258.09,"magnitude":3.76},{"hr":8450,"altitude":60.49,"azimuth":218.86,"magnitude":3.53},{"hr":8465,"altitude":59.5,"azimuth":341.35,"magnitude":3.35},{"hr":8518,"altitude":55.14,"azimuth":207.38,"magnitude":3.84},{"hr":8556,"altitude":15.2,"azimuth":189.99,"magnitude":3.97},{"hr":8571,"altitude":60.43,"azimuth":345.8,"magnitude":3.75},{"hr":8585,"altitude":67.82,"azimuth":337.92,"magnitude":3.77},{"hr":8634,"altitude":68.31,"azimuth":208.35,"magnitude":3.4},{"hr":8636,"altitude":12.29,"azimuth":186.98,"magnitude":2.1},{"hr":8650,"altitude":81.45,"azimuth":271.98,"magnitude":2.94},{"hr":8667,"altitude":79.52,"azimuth":232.2,"magnitude":3.95},{"hr":8675,"altitude":8.04,"azimuth":185.37,"magnitude":3.49},{"hr":8684,"altitude":80.79,"azimuth":233.73,"magnitude":3.48},{"hr":8694,"altitude":53.75,"azimuth":354.4,"magnitude":3.52},{"hr":8698,"altitude":51.44,"azimuth":191.99,"magnitude":3.74},{"hr":8709,"altitude":43.39,"azimuth":189.28,"magnitude":3.27},{"hr":8728,"altitude":29.78,"azimuth":186.26,"magnitude":1.16},{"hr":8762,"altitude":77.28,"azimuth":342.37,"magnitude":3.62},{"hr":8775,"altitude":85.33,"azimuth":242.84,"magnitude":2.42},{"hr":8781,"altitude":74.36,"azimuth":196.19,"magnitude":2.49},{"hr":8812,"altitude":38.43,"azimuth":183.93,"magnitude":3.66},{"hr":8820,"altitude":14.4,"azimuth":182.23,"magnitude":3.9},{"hr":8848,"altitude":1.46,"azimuth":180.69,"magnitude":3.99},{"hr":8852,"altitude":62.95,"azimuth":183.0,"magnitude":3.69},{"hr":8892,"altitude":39.6,"azimuth":179.9,"magnitude":3.97},{"hr":8961,"altitude":73.58,"azimuth":9.14,"magnitude":3.82},{"hr":8974,"altitude":42.63,"azimuth":1.22,"magnitude":3.21}],"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]]}],"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"}},"twilight_morning":{"phase":"twilight_morning","sun_altitude":-3.61,"planets":[{"id":"sun","altitude":-3.61,"azimuth":65.0},{"id":"moon","altitude":-48.03,"azimuth":343.35},{"id":"mercury","altitude":-14.14,"azimuth":49.63},{"id":"venus","altitude":-21.69,"azimuth":41.02},{"id":"mars","altitude":16.66,"azimuth":89.14},{"id":"jupiter","altitude":-8.3,"azimuth":260.02},{"id":"saturn","altitude":38.18,"azimuth":133.21}],"stars":[{"hr":15,"altitude":43.47,"azimuth":77.2,"magnitude":2.06},{"hr":21,"altitude":43.76,"azimuth":35.22,"magnitude":2.27},{"hr":39,"altitude":37.35,"azimuth":93.71,"magnitude":2.83},{"hr":74,"altitude":22.92,"azimuth":116.13,"magnitude":3.56},{"hr":153,"altitude":40.73,"azimuth":42.99,"magnitude":3.66},{"hr":165,"altitude":37.51,"azimuth":72.54,"magnitude":3.27},{"hr":168,"altitude":40.06,"azimuth":39.57,"magnitude":2.23},{"hr":188,"altitude":12.64,"azimuth":119.86,"magnitude":2.04},{"hr":219,"altitude":38.81,"azimuth":38.03,"magnitude":3.44},{"hr":264,"altitude":37.64,"azimuth":34.43,"magnitude":2.47},{"hr":269,"altitude":35.95,"azimuth":62.19,"magnitude":3.87},{"hr":334,"altitude":12.34,"azimuth":109.75,"magnitude":3.45},{"hr":337,"altitude":32.73,"azimuth":64.79,"magnitude":2.06},{"hr":402,"altitude":10.28,"azimuth":105.9,"magnitude":3.6},{"hr":403,"altitude":34.06,"azimuth":35.08,"magnitude":2.68},{"hr":424,"altitude":30.29,"azimuth":0.88,"magnitude":2.02},{"hr":437,"altitude":20.56,"azimuth":83.82,"magnitude":3.62},{"hr":464,"altitude":30.8,"azimuth":48.45,"magnitude":3.57},{"hr":509,"altitude":1.97,"azimuth":109.8,"magnitude":3.5},{"hr":539,"altitude":3.41,"azimuth":104.09,"magnitude":3.73},{"hr":542,"altitude":30.98,"azimuth":30.72,"magnitude":3.38},{"hr":544,"altitude":22.1,"azimuth":67.71,"magnitude":3.41},{"hr":553,"altitude":18.09,"azimuth":76.03,"magnitude":2.64},{"hr":580,"altitude":30.89,"azimuth":20.47,"magnitude":3.98},{"hr":603,"altitude":24.71,"azimuth":53.89,"magnitude":2.26},{"hr":617,"altitude":16.67,"azimuth":72.18,"magnitude":2.0},{"hr":622,"altitude":21.02,"azimuth":60.89,"magnitude":3.0},{"hr":681,"altitude":1.25,"azimuth":94.21,"magnitude":3.04},{"hr":834,"altitude":22.24,"azimuth":37.17,"magnitude":3.76},{"hr":838,"altitude":9.86,"azimuth":64.12,"magnitude":3.63},{"hr":854,"altitude":20.56,"azimuth":40.06,"magnitude":3.95},{"hr":915,"altitude":19.42,"azimuth":38.65,"magnitude":2.93},{"hr":921,"altitude":12.69,"azimuth":52.22,"magnitude":3.39},{"hr":936,"altitude":13.21,"azimuth":50.04,"magnitude":2.12},{"hr":941,"altitude":14.85,"azimuth":46.38,"magnitude":3.8},{"hr":1017,"altitude":15.06,"azimuth":40.6,"magnitude":1.79},{"hr":1122,"altitude":11.37,"azimuth":40.79,"magnitude":3.01},{"hr":1131,"altitude":2.48,"azimuth":53.59,"magnitude":3.83},{"hr":1135,"altitude":8.16,"azimuth":44.97,"magnitude":3.77},{"hr":1203,"altitude":0.55,"azimuth":52.72,"magnitude":2.85},{"hr":1220,"altitude":4.74,"azimuth":45.7,"magnitude":2.89},{"hr":2077,"altitude":0.75,"azimuth":21.17,"magnitude":3.72},{"hr":3323,"altitude":1.01,"azimuth":0.25,"magnitude":3.36},{"hr":3757,"altitude":4.1,"azimuth":353.34,"magnitude":3.67},{"hr":3888,"altitude":0.82,"azimuth":350.03,"magnitude":3.8},{"hr":4295,"altitude":2.31,"azimuth":340.35,"magnitude":2.37},{"hr":4301,"altitude":7.0,"azimuth":342.99,"magnitude":1.79},{"hr":4434,"altitude":14.82,"azimuth":345.11,"magnitude":3.84},{"hr":4554,"altitude":4.63,"azimuth":332.78,"magnitude":2.44},{"hr":4660,"altitude":9.16,"azimuth":332.9,"magnitude":3.31},{"hr":4787,"altitude":19.01,"azimuth":341.51,"magnitude":3.87},{"hr":4905,"altitude":12.55,"azimuth":328.58,"magnitude":1.77},{"hr":4915,"altitude":2.12,"azimuth":314.21,"magnitude":2.9},{"hr":5054,"altitude":15.5,"azimuth":325.27,"magnitude":2.27},{"hr":5055,"altitude":15.5,"azimuth":325.26,"magnitude":3.95},{"hr":5191,"altitude":15.88,"azimuth":318.35,"magnitude":1.86},{"hr":5235,"altitude":1.49,"azimuth":290.54,"magnitude":2.68},{"hr":5291,"altitude":24.19,"azimuth":331.95,"magnitude":3.65},{"hr":5340,"altitude":6.16,"azimuth":288.68,"magnitude":-0.04},{"hr":5429,"altitude":14.72,"azimuth":296.9,"magnitude":3.58},{"hr":5435,"altitude":18.21,"azimuth":304.33,"magnitude":3.03},{"hr":5506,"altitude":15.83,"azimuth":292.5,"magnitude":2.7},{"hr":5511,"altitude":4.0,"azimuth":269.88,"magnitude":3.72},{"hr":5563,"altitude":30.29,"azimuth":341.66,"magnitude":2.08},{"hr":5602,"altitude":24.34,"azimuth":304.04,"magnitude":3.5},{"hr":5681,"altitude":24.38,"azimuth":295.73,"magnitude":3.47},{"hr":5685,"altitude":4.79,"azimuth":256.23,"magnitude":2.61},{"hr":5735,"altitude":32.41,"azimuth":338.87,"magnitude":3.05},{"hr":5744,"altitude":32.27,"azimuth":323.62,"magnitude":3.29},{"hr":5747,"altitude":25.27,"azimuth":290.34,"magnitude":3.68},{"hr":5787,"altitude":5.7,"azimuth":249.21,"magnitude":3.91},{"hr":5788,"altitude":18.76,"azimuth":271.48,"magnitude":3.8},{"hr":5789,"altitude":18.76,"azimuth":271.48,"magnitude":3.8},{"hr":5793,"altitude":25.77,"azimuth":287.25,"magnitude":2.23},{"hr":5849,"altitude":27.27,"azimuth":286.05,"magnitude":3.84},{"hr":5854,"altitude":18.79,"azimuth":266.48,"magnitude":2.65},{"hr":5867,"altitude":23.48,"azimuth":274.73,"magnitude":3.67},{"hr":5881,"altitude":14.82,"azimuth":256.95,"magnitude":3.53},{"hr":5892,"altitude":19.22,"azimuth":263.83,"magnitude":3.71},{"hr":5928,"altitude":1.51,"azimuth":234.52,"magnitude":3.88},{"hr":5933,"altitude":25.8,"azimuth":273.75,"magnitude":3.85},{"hr":5944,"altitude":3.7,"azimuth":236.76,"magnitude":2.89},{"hr":5953,"altitude":6.04,"azimuth":239.38,"magnitude":2.32},{"hr":5958,"altitude":30.63,"azimuth":284.06,"magnitude":2.0},{"hr":5984,"altitude":8.68,"azimuth":240.95,"magnitude":2.62},{"hr":5993,"altitude":8.42,"azimuth":240.05,"magnitude":3.96},{"hr":6056,"altitude":19.83,"azimuth":253.17,"magnitude":2.74},{"hr":6075,"altitude":20.09,"azimuth":251.7,"magnitude":3.24},{"hr":6084,"altitude":7.99,"azimuth":234.08,"magnitude":2.89},{"hr":6092,"altitude":39.39,"azimuth":307.18,"magnitude":3.89},{"hr":6095,"altitude":32.79,"azimuth":274.37,"magnitude":3.75},{"hr":6132,"altitude":39.53,"azimuth":326.93,"magnitude":2.74},{"hr":6134,"altitude":8.87,"azimuth":232.23,"magnitude":0.96},{"hr":6148,"altitude":35.5,"azimuth":276.02,"magnitude":2.77},{"hr":6149,"altitude":26.42,"azimuth":255.82,"magnitude":3.82},{"hr":6165,"altitude":8.79,"azimuth":229.91,"magnitude":2.82},{"hr":6175,"altitude":20.36,"azimuth":243.71,"magnitude":2.56},{"hr":6212,"altitude":41.09,"azimuth":287.24,"magnitude":2.81},{"hr":6220,"altitude":42.82,"azimuth":296.82,"magnitude":3.53},{"hr":6241,"altitude":6.89,"azimuth":223.32,"magnitude":2.29},{"hr":6247,"altitude":4.51,"azimuth":220.39,"magnitude":3.08},{"hr":6252,"altitude":4.59,"azimuth":220.34,"magnitude":3.57},{"hr":6271,"altitude":1.82,"azimuth":216.97,"magnitude":3.62},{"hr":6299,"altitude":35.99,"azimuth":259.02,"magnitude":3.2},{"hr":6324,"altitude":44.87,"azimuth":285.0,"magnitude":3.92},{"hr":6378,"altitude":23.12,"azimuth":233.82,"magnitude":2.43},{"hr":6380,"altitude":3.38,"azimuth":214.01,"magnitude":3.33},{"hr":6396,"altitude":43.16,"azimuth":334.11,"magnitude":3.17},{"hr":6406,"altitude":42.07,"azimuth":262.01,"magnitude":3.48},{"hr":6410,"altitude":46.32,"azimuth":275.35,"magnitude":3.14},{"hr":6418,"altitude":48.88,"azimuth":292.73,"magnitude":3.16},{"hr":6453,"altitude":18.38,"azimuth":224.81,"magnitude":3.27},{"hr":6508,"altitude":10.21,"azimuth":215.12,"magnitude":2.69},{"hr":6510,"altitude":0.42,"azimuth":207.17,"magnitude":2.95},{"hr":6527,"altitude":10.71,"azimuth":214.79,"magnitude":1.63},{"hr":6536,"altitude":50.33,"azimuth":317.01,"magnitude":2.79},{"hr":6553,"altitude":6.47,"azimuth":210.59,"magnitude":1.87},{"hr":6556,"altitude":45.47,"azimuth":256.45,"magnitude":2.08},{"hr":6561,"altitude":27.94,"azimuth":228.89,"magnitude":3.54},{"hr":6580,"altitude":10.23,"azimuth":212.19,"magnitude":2.41},{"hr":6588,"altitude":53.16,"azimuth":307.61,"magnitude":3.8},{"hr":6603,"altitude":42.7,"azimuth":245.6,"magnitude":2.77},{"hr":6615,"altitude":9.92,"azimuth":210.73,"magnitude":3.03},{"hr":6623,"altitude":53.89,"azimuth":276.54,"magnitude":3.42},{"hr":6629,"altitude":42.39,"azimuth":242.69,"magnitude":3.75},{"hr":6630,"altitude":12.69,"azimuth":212.17,"magnitude":3.21},{"hr":6688,"altitude":51.79,"azimuth":325.71,"magnitude":3.75},{"hr":6695,"altitude":57.11,"azimuth":292.84,"magnitude":3.86},{"hr":6698,"altitude":35.62,"azimuth":228.7,"magnitude":3.34},{"hr":6703,"altitude":56.65,"azimuth":278.18,"magnitude":3.7},{"hr":6705,"altitude":54.41,"azimuth":317.84,"magnitude":2.23},{"hr":6714,"altitude":44.96,"azimuth":240.06,"magnitude":3.97},{"hr":6743,"altitude":3.39,"azimuth":202.43,"magnitude":3.66},{"hr":6746,"altitude":20.03,"azimuth":213.19,"magnitude":2.99},{"hr":6771,"altitude":50.44,"azimuth":246.14,"magnitude":3.73},{"hr":6779,"altitude":58.65,"azimuth":276.48,"magnitude":3.83},{"hr":6812,"altitude":28.83,"azimuth":217.24,"magnitude":3.86},{"hr":6832,"altitude":15.91,"azimuth":207.5,"magnitude":3.11},{"hr":6859,"altitude":22.27,"azimuth":210.53,"magnitude":2.7},{"hr":6869,"altitude":44.44,"azimuth":229.12,"magnitude":3.26},{"hr":6879,"altitude":18.64,"azimuth":207.5,"magnitude":1.85},{"hr":6895,"altitude":60.04,"azimuth":261.22,"magnitude":3.84},{"hr":6897,"altitude":8.64,"azimuth":201.44,"magnitude":3.51},{"hr":6913,"altitude":26.85,"azimuth":211.49,"magnitude":2.81},{"hr":6927,"altitude":44.2,"azimuth":347.08,"magnitude":3.57},{"hr":6973,"altitude":42.33,"azimuth":220.85,"magnitude":3.85},{"hr":7001,"altitude":64.93,"azimuth":297.63,"magnitude":0.03},{"hr":7039,"altitude":27.32,"azimuth":206.73,"magnitude":3.17},{"hr":7106,"altitude":68.16,"azimuth":284.78,"magnitude":3.45},{"hr":7121,"altitude":28.85,"azimuth":204.85,"magnitude":2.02},{"hr":7150,"altitude":33.82,"azimuth":206.74,"magnitude":3.51},{"hr":7178,"altitude":70.03,"azimuth":282.97,"magnitude":3.24},{"hr":7194,"altitude":26.17,"azimuth":201.61,"magnitude":2.6},{"hr":7217,"altitude":33.89,"azimuth":204.65,"magnitude":3.77},{"hr":7234,"altitude":28.57,"azimuth":201.51,"magnitude":3.32},{"hr":7235,"altitude":64.15,"azimuth":235.35,"magnitude":2.99},{"hr":7236,"altitude":49.27,"azimuth":214.0,"magnitude":3.44},{"hr":7264,"altitude":35.0,"azimuth":203.65,"magnitude":2.89},{"hr":7310,"altitude":50.82,"azimuth":348.22,"magnitude":3.07},{"hr":7328,"altitude":63.2,"azimuth":334.85,"magnitude":3.77},{"hr":7340,"altitude":38.96,"azimuth":201.77,"magnitude":3.93},{"hr":7348,"altitude":17.36,"azimuth":193.52,"magnitude":3.97},{"hr":7377,"altitude":58.59,"azimuth":213.33,"magnitude":3.36},{"hr":7417,"altitude":76.4,"azimuth":263.98,"magnitude":3.08},{"hr":7420,"altitude":65.66,"azimuth":336.19,"magnitude":3.79},{"hr":7525,"altitude":67.62,"azimuth":210.9,"magnitude":2.72},{"hr":7528,"altitude":72.52,"azimuth":331.37,"magnitude":2.87},{"hr":7536,"altitude":74.47,"azimuth":223.4,"magnitude":3.82},{"hr":7557,"altitude":66.5,"azimuth":206.42,"magnitude":0.77},{"hr":7570,"altitude":59.24,"azimuth":199.69,"magnitude":3.9},{"hr":7582,"altitude":49.55,"azimuth":354.34,"magnitude":3.83},{"hr":7602,"altitude":64.6,"azimuth":201.77,"magnitude":3.71},{"hr":7615,"altitude":81.08,"azimuth":304.86,"magnitude":3.89},{"hr":7635,"altitude":76.83,"azimuth":216.9,"magnitude":3.47},{"hr":7710,"altitude":58.5,"azimuth":190.02,"magnitude":3.23},{"hr":7735,"altitude":73.16,"azimuth":349.04,"magnitude":3.79},{"hr":7751,"altitude":72.28,"azimuth":350.8,"magnitude":3.98},{"hr":7754,"altitude":47.03,"azimuth":185.07,"magnitude":3.57},{"hr":7776,"altitude":44.85,"azimuth":183.82,"magnitude":3.08},{"hr":7790,"altitude":2.96,"azimuth":180.92,"magnitude":1.94},{"hr":7796,"altitude":79.83,"azimuth":349.28,"magnitude":2.2},{"hr":7869,"altitude":12.41,"azimuth":179.08,"magnitude":3.11},{"hr":7882,"altitude":74.26,"azimuth":175.18,"magnitude":3.63},{"hr":7906,"altitude":75.52,"azimuth":172.78,"magnitude":3.77},{"hr":7924,"altitude":74.9,"azimuth":6.33,"magnitude":1.25},{"hr":7949,"altitude":85.26,"azimuth":38.14,"magnitude":2.46},{"hr":7950,"altitude":50.04,"azimuth":174.05,"magnitude":3.77},{"hr":7957,"altitude":58.38,"azimuth":2.99,"magnitude":3.43},{"hr":7986,"altitude":1.13,"azimuth":177.06,"magnitude":3.65},{"hr":8028,"altitude":78.0,"azimuth":23.3,"magnitude":3.94},{"hr":8079,"altitude":74.89,"azimuth":23.25,"magnitude":3.72},{"hr":8115,"altitude":81.19,"azimuth":87.86,"magnitude":3.2},{"hr":8130,"altitude":78.26,"azimuth":45.82,"magnitude":3.72},{"hr":8131,"altitude":62.93,"azimuth":155.53,"magnitude":3.92},{"hr":8162,"altitude":56.84,"azimuth":9.79,"magnitude":2.44},{"hr":8204,"altitude":35.7,"azimuth":164.46,"magnitude":3.74},{"hr":8232,"altitude":51.42,"azimuth":155.87,"magnitude":2.91},{"hr":8238,"altitude":48.96,"azimuth":7.14,"magnitude":3.23},{"hr":8278,"altitude":40.28,"azimuth":158.5,"magnitude":3.68},{"hr":8308,"altitude":63.57,"azimuth":136.82,"magnitude":2.39},{"hr":8322,"altitude":40.2,"azimuth":156.21,"magnitude":2.87},{"hr":8353,"altitude":19.69,"azimuth":162.87,"magnitude":3.01},{"hr":8414,"altitude":52.14,"azimuth":139.67,"magnitude":2.96},{"hr":8425,"altitude":9.77,"azimuth":163.65,"magnitude":1.74},{"hr":8430,"altitude":68.49,"azimuth":97.5,"magnitude":3.76},{"hr":8450,"altitude":56.66,"azimuth":131.37,"magnitude":3.53},{"hr":8465,"altitude":57.36,"azimuth":24.11,"magnitude":3.35},{"hr":8518,"altitude":48.96,"azimuth":135.57,"magnitude":3.84},{"hr":8556,"altitude":11.49,"azimuth":158.8,"magnitude":3.97},{"hr":8571,"altitude":55.51,"azimuth":26.91,"magnitude":3.75},{"hr":8585,"altitude":60.09,"azimuth":39.58,"magnitude":3.77},{"hr":8634,"altitude":54.23,"azimuth":116.03,"magnitude":3.4},{"hr":8636,"altitude":7.4,"azimuth":158.21,"magnitude":2.1},{"hr":8650,"altitude":61.84,"azimuth":81.75,"magnitude":2.94},{"hr":8667,"altitude":59.41,"azimuth":94.52,"magnitude":3.95},{"hr":8675,"altitude":3.05,"azimuth":159.48,"magnitude":3.49},{"hr":8684,"altitude":59.01,"azimuth":92.1,"magnitude":3.48},{"hr":8694,"altitude":48.51,"azimuth":20.15,"magnitude":3.52},{"hr":8698,"altitude":39.31,"azimuth":132.54,"magnitude":3.74},{"hr":8709,"altitude":32.53,"azimuth":138.36,"magnitude":3.27},{"hr":8728,"altitude":20.79,"azimuth":146.55,"magnitude":1.16},{"hr":8762,"altitude":57.82,"azimuth":57.62,"magnitude":3.62},{"hr":8775,"altitude":56.98,"azimuth":84.19,"magnitude":2.42},{"hr":8781,"altitude":51.95,"azimuth":104.71,"magnitude":2.49},{"hr":8812,"altitude":26.14,"azimuth":138.85,"magnitude":3.66},{"hr":8820,"altitude":6.35,"azimuth":153.21,"magnitude":3.9},{"hr":8852,"altitude":42.6,"azimuth":116.58,"magnitude":3.69},{"hr":8892,"altitude":24.99,"azimuth":135.38,"magnitude":3.97},{"hr":8961,"altitude":50.9,"azimuth":52.27,"magnitude":3.82},{"hr":8974,"altitude":38.26,"azimuth":11.5,"magnitude":3.21}],"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]]}],"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"}},"twilight_evening":{"phase":"twilight_evening","sun_altitude":-5.18,"planets":[{"id":"sun","altitude":-5.18,"azimuth":296.23},{"id":"moon","altitude":62.55,"azimuth":218.2},{"id":"mercury","altitude":13.1,"azimuth":291.43},{"id":"venus","altitude":22.83,"azimuth":286.36},{"id":"mars","altitude":-34.43,"azimuth":308.64},{"id":"jupiter","altitude":23.97,"azimuth":122.62},{"id":"saturn","altitude":-68.54,"azimuth":0.32}],"stars":[{"hr":21,"altitude":0.72,"azimuth":350.55,"magnitude":2.27},{"hr":219,"altitude":1.28,"azimuth":345.2,"magnitude":3.44},{"hr":264,"altitude":4.32,"azimuth":345.56,"magnitude":2.47},{"hr":403,"altitude":5.65,"azimuth":342.18,"magnitude":2.68},{"hr":424,"altitude":29.81,"azimuth":359.3,"magnitude":2.02},{"hr":542,"altitude":10.38,"azimuth":341.41,"magnitude":3.38},{"hr":580,"altitude":17.63,"azimuth":346.54,"magnitude":3.98},{"hr":834,"altitude":9.66,"azimuth":330.8,"magnitude":3.76},{"hr":854,"altitude":7.99,"azimuth":328.07,"magnitude":3.95},{"hr":915,"altitude":9.68,"azimuth":327.62,"magnitude":2.93},{"hr":921,"altitude":0.39,"azimuth":316.21,"magnitude":3.39},{"hr":936,"altitude":2.18,"azimuth":317.47,"magnitude":2.12},{"hr":941,"altitude":4.83,"azimuth":320.35,"magnitude":3.8},{"hr":1017,"altitude":9.87,"azimuth":322.81,"magnitude":1.79},{"hr":1122,"altitude":11.22,"azimuth":319.31,"magnitude":3.01},{"hr":1131,"altitude":2.79,"azimuth":306.19,"magnitude":3.83},{"hr":1135,"altitude":8.69,"azimuth":314.67,"magnitude":3.77},{"hr":1203,"altitude":4.29,"azimuth":304.69,"magnitude":2.85},{"hr":1220,"altitude":9.31,"azimuth":311.18,"magnitude":2.89},{"hr":1346,"altitude":0.57,"azimuth":287.81,"magnitude":3.65},{"hr":1373,"altitude":2.21,"azimuth":289.06,"magnitude":3.76},{"hr":1409,"altitude":4.21,"azimuth":289.77,"magnitude":3.53},{"hr":1411,"altitude":2.55,"azimuth":287.01,"magnitude":3.84},{"hr":1412,"altitude":2.53,"azimuth":286.92,"magnitude":3.4},{"hr":1457,"altitude":4.35,"azimuth":286.58,"magnitude":0.85},{"hr":1543,"altitude":2.49,"azimuth":276.6,"magnitude":3.19},{"hr":1552,"altitude":2.1,"azimuth":275.25,"magnitude":3.69},{"hr":1567,"altitude":1.16,"azimuth":272.14,"magnitude":3.72},{"hr":1577,"altitude":16.45,"azimuth":299.21,"magnitude":2.69},{"hr":1605,"altitude":21.65,"azimuth":309.11,"magnitude":2.99},{"hr":1612,"altitude":20.7,"azimuth":306.34,"magnitude":3.75},{"hr":1641,"altitude":21.46,"azimuth":306.19,"magnitude":3.17},{"hr":1666,"altitude":0.3,"azimuth":263.92,"magnitude":2.79},{"hr":1708,"altitude":24.84,"azimuth":310.35,"magnitude":0.08},{"hr":1713,"altitude":0.15,"azimuth":260.4,"magnitude":0.12},{"hr":1735,"altitude":1.5,"azimuth":261.18,"magnitude":3.6},{"hr":1788,"altitude":5.24,"azimuth":264.13,"magnitude":3.36},{"hr":1790,"altitude":9.78,"azimuth":271.67,"magnitude":1.64},{"hr":1791,"altitude":20.23,"azimuth":292.06,"magnitude":1.65},{"hr":1852,"altitude":7.93,"azimuth":264.98,"magnitude":2.23},{"hr":1879,"altitude":13.7,"azimuth":273.63,"magnitude":3.54},{"hr":1899,"altitude":5.78,"azimuth":259.69,"magnitude":2.77},{"hr":1903,"altitude":8.37,"azimuth":263.66,"magnitude":1.7},{"hr":1910,"altitude":19.41,"azimuth":283.72,"magnitude":3.0},{"hr":1931,"altitude":8.2,"azimuth":262.11,"magnitude":3.81},{"hr":1948,"altitude":8.97,"azimuth":262.42,"magnitude":2.05},{"hr":1998,"altitude":3.47,"azimuth":250.6,"magnitude":3.55},{"hr":2004,"altitude":6.39,"azimuth":254.86,"magnitude":2.06},{"hr":2012,"altitude":28.86,"azimuth":300.86,"magnitude":3.97},{"hr":2035,"altitude":1.06,"azimuth":244.94,"magnitude":3.81},{"hr":2061,"altitude":16.79,"azimuth":268.84,"magnitude":0.5},{"hr":2077,"altitude":33.37,"azimuth":317.84,"magnitude":3.72},{"hr":2085,"altitude":5.74,"azimuth":249.91,"magnitude":3.71},{"hr":2088,"altitude":31.79,"azimuth":306.91,"magnitude":1.9},{"hr":2095,"altitude":29.84,"azimuth":298.19,"magnitude":2.62},{"hr":2216,"altitude":27.83,"azimuth":281.14,"magnitude":3.28},{"hr":2227,"altitude":13.87,"azimuth":254.07,"magnitude":3.98},{"hr":2282,"altitude":1.2,"azimuth":233.67,"magnitude":3.02},{"hr":2286,"altitude":29.55,"azimuth":280.3,"magnitude":2.88},{"hr":2294,"altitude":8.81,"azimuth":243.15,"magnitude":1.98},{"hr":2421,"altitude":30.18,"azimuth":272.22,"magnitude":1.93},{"hr":2429,"altitude":10.69,"azimuth":240.09,"magnitude":3.95},{"hr":2473,"altitude":34.97,"azimuth":281.07,"magnitude":2.98},{"hr":2484,"altitude":30.24,"azimuth":267.65,"magnitude":3.36},{"hr":2491,"altitude":13.83,"azimuth":240.88,"magnitude":-1.46},{"hr":2538,"altitude":4.62,"azimuth":227.83,"magnitude":3.96},{"hr":2540,"altitude":39.41,"azimuth":290.96,"magnitude":3.6},{"hr":2580,"altitude":10.75,"azimuth":233.6,"magnitude":3.87},{"hr":2618,"altitude":8.37,"azimuth":229.26,"magnitude":1.5},{"hr":2646,"altitude":9.57,"azimuth":229.59,"magnitude":3.47},{"hr":2650,"altitude":37.61,"azimuth":273.68,"magnitude":3.79},{"hr":2653,"altitude":12.51,"azimuth":232.51,"magnitude":3.02},{"hr":2693,"altitude":11.7,"azimuth":229.73,"magnitude":1.84},{"hr":2749,"altitude":12.49,"azimuth":228.45,"magnitude":3.85},{"hr":2763,"altitude":38.96,"azimuth":267.26,"magnitude":3.58},{"hr":2773,"altitude":5.61,"azimuth":220.63,"magnitude":2.7},{"hr":2777,"altitude":41.59,"azimuth":273.54,"magnitude":3.53},{"hr":2821,"altitude":44.68,"azimuth":280.52,"magnitude":3.79},{"hr":2827,"altitude":12.18,"azimuth":225.13,"magnitude":2.45},{"hr":2845,"altitude":36.92,"azimuth":256.74,"magnitude":2.9},{"hr":2878,"altitude":2.72,"azimuth":214.66,"magnitude":3.25},{"hr":2890,"altitude":47.53,"azimuth":285.58,"magnitude":2.88},{"hr":2891,"altitude":47.53,"azimuth":285.58,"magnitude":1.98},{"hr":2943,"altitude":37.76,"azimuth":251.43,"magnitude":0.38},{"hr":2970,"altitude":28.87,"azimuth":237.25,"magnitude":3.93},{"hr":2985,"altitude":47.67,"azimuth":274.01,"magnitude":3.57},{"hr":2990,"altitude":48.91,"azimuth":279.14,"magnitude":1.14},{"hr":2996,"altitude":15.37,"azimuth":222.14,"magnitude":3.96},{"hr":3017,"altitude":8.73,"azimuth":215.89,"magnitude":3.61},{"hr":3045,"altitude":19.25,"azimuth":224.02,"magnitude":3.34},{"hr":3080,"altitude":7.57,"azimuth":213.17,"magnitude":3.73},{"hr":3165,"altitude":9.34,"azimuth":211.78,"magnitude":2.25},{"hr":3185,"altitude":22.34,"azimuth":221.0,"magnitude":2.81},{"hr":3207,"altitude":4.0,"azimuth":206.6,"magnitude":1.78},{"hr":3249,"altitude":47.59,"azimuth":248.63,"magnitude":3.52},{"hr":3314,"altitude":40.69,"azimuth":232.74,"magnitude":3.9},{"hr":3323,"altitude":51.34,"azimuth":332.54,"magnitude":3.36},{"hr":3438,"altitude":17.22,"azimuth":208.21,"magnitude":3.97},{"hr":3445,"altitude":7.38,"azimuth":202.44,"magnitude":3.84},{"hr":3447,"altitude":1.85,"azimuth":199.47,"magnitude":3.62},{"hr":3461,"altitude":58.16,"azimuth":255.21,"magnitude":3.94},{"hr":3468,"altitude":19.42,"azimuth":208.67,"magnitude":3.68},{"hr":3482,"altitude":51.7,"azimuth":238.02,"magnitude":3.38},{"hr":3485,"altitude":0.58,"azimuth":198.06,"magnitude":1.96},{"hr":3487,"altitude":8.35,"azimuth":201.9,"magnitude":3.91},{"hr":3547,"altitude":52.93,"azimuth":235.05,"magnitude":3.11},{"hr":3569,"altitude":61.7,"azimuth":317.13,"magnitude":3.14},{"hr":3579,"altitude":64.4,"azimuth":304.62,"magnitude":3.97},{"hr":3594,"altitude":62.78,"azimuth":316.22,"magnitude":3.6},{"hr":3614,"altitude":8.76,"azimuth":198.6,"magnitude":3.75},{"hr":3634,"altitude":12.39,"azimuth":199.46,"magnitude":2.21},{"hr":3665,"altitude":53.36,"azimuth":225.12,"magnitude":3.88},{"hr":3690,"altitude":69.07,"azimuth":294.63,"magnitude":3.82},{"hr":3705,"altitude":69.86,"azimuth":288.01,"magnitude":3.13},{"hr":3734,"altitude":2.47,"azimuth":193.01,"magnitude":2.5},{"hr":3748,"altitude":45.85,"azimuth":211.71,"magnitude":1.98},{"hr":3757,"altitude":54.58,"azimuth":343.91,"magnitude":3.67},{"hr":3775,"altitude":63.79,"azimuth":330.66,"magnitude":3.17},{"hr":3786,"altitude":16.67,"azimuth":196.5,"magnitude":3.6},{"hr":3803,"altitude":0.96,"azimuth":191.15,"magnitude":3.13},{"hr":3845,"altitude":53.92,"azimuth":212.92,"magnitude":3.91},{"hr":3852,"altitude":63.42,"azimuth":223.87,"magnitude":3.52},{"hr":3873,"altitude":73.41,"azimuth":251.18,"magnitude":2.98},{"hr":3888,"altitude":59.24,"azimuth":344.0,"magnitude":3.8},{"hr":3905,"altitude":75.75,"azimuth":256.49,"magnitude":3.88},{"hr":3940,"altitude":4.28,"azimuth":188.31,"magnitude":3.54},{"hr":3975,"altitude":72.74,"azimuth":221.3,"magnitude":3.52},{"hr":3982,"altitude":68.82,"azimuth":212.81,"magnitude":1.35},{"hr":3994,"altitude":46.11,"azimuth":195.57,"magnitude":3.61},{"hr":4023,"altitude":17.06,"azimuth":187.7,"magnitude":3.85},{"hr":4031,"altitude":79.15,"azimuth":233.28,"magnitude":3.44},{"hr":4033,"altitude":75.29,"azimuth":332.01,"magnitude":3.45},{"hr":4057,"altitude":76.99,"azimuth":218.91,"magnitude":2.61},{"hr":4058,"altitude":76.99,"azimuth":218.9,"magnitude":3.8},{"hr":4069,"altitude":77.0,"azimuth":332.18,"magnitude":3.05},{"hr":4094,"altitude":42.43,"azimuth":189.23,"magnitude":3.81},{"hr":4114,"altitude":0.85,"azimuth":183.44,"magnitude":3.82},{"hr":4133,"altitude":68.46,"azimuth":194.74,"magnitude":3.85},{"hr":4167,"altitude":11.44,"azimuth":182.92,"magnitude":3.84},{"hr":4216,"altitude":10.32,"azimuth":181.28,"magnitude":2.69},{"hr":4232,"altitude":43.55,"azimuth":181.62,"magnitude":3.11},{"hr":4247,"altitude":86.02,"azimuth":356.31,"magnitude":3.83},{"hr":4257,"altitude":0.91,"azimuth":180.13,"magnitude":3.78},{"hr":4295,"altitude":63.83,"azimuth":2.28,"magnitude":2.37},{"hr":4301,"altitude":58.45,"azimuth":2.07,"magnitude":1.79},{"hr":4335,"altitude":75.43,"azimuth":10.76,"magnitude":3.01},{"hr":4337,"altitude":0.74,"azimuth":178.18,"magnitude":3.91},{"hr":4357,"altitude":79.33,"azimuth":154.45,"magnitude":2.56},{"hr":4359,"altitude":74.52,"azimuth":161.94,"magnitude":3.34},{"hr":4377,"altitude":84.16,"azimuth":59.17,"magnitude":3.48},{"hr":4382,"altitude":44.59,"azimuth":171.56,"magnitude":3.56},{"hr":4390,"altitude":5.08,"azimuth":176.14,"magnitude":3.89},{"hr":4399,"altitude":69.14,"azimuth":159.33,"magnitude":3.94},{"hr":4434,"altitude":50.55,"azimuth":5.09,"magnitude":3.84},{"hr":4450,"altitude":27.24,"azimuth":170.81,"magnitude":3.54},{"hr":4518,"altitude":69.87,"azimuth":25.78,"magnitude":3.71},{"hr":4534,"altitude":69.93,"azimuth":138.33,"magnitude":2.14},{"hr":4540,"altitude":58.57,"azimuth":152.29,"magnitude":3.61},{"hr":4554,"altitude":64.21,"azimuth":20.36,"magnitude":2.44},{"hr":4621,"altitude":7.41,"azimuth":168.33,"magnitude":2.6},{"hr":4630,"altitude":34.11,"azimuth":158.83,"magnitude":3.0},{"hr":4638,"altitude":5.69,"azimuth":168.3,"magnitude":3.96},{"hr":4660,"altitude":59.73,"azimuth":21.9,"magnitude":3.31},{"hr":4662,"altitude":38.37,"azimuth":155.02,"magnitude":2.59},{"hr":4689,"altitude":53.01,"azimuth":142.77,"magnitude":3.89},{"hr":4743,"altitude":6.9,"azimuth":165.18,"magnitude":3.91},{"hr":4757,"altitude":37.92,"azimuth":150.58,"magnitude":2.95},{"hr":4763,"altitude":0.29,"azimuth":167.15,"magnitude":1.63},{"hr":4786,"altitude":31.26,"azimuth":153.05,"magnitude":2.65},{"hr":4787,"altitude":48.05,"azimuth":12.48,"magnitude":3.87},{"hr":4802,"altitude":7.91,"azimuth":163.08,"magnitude":3.86},{"hr":4819,"altitude":7.28,"azimuth":162.66,"magnitude":2.17},{"hr":4825,"altitude":49.31,"azimuth":136.29,"magnitude":3.65},{"hr":4826,"altitude":49.31,"azimuth":136.29,"magnitude":3.68},{"hr":4905,"altitude":56.79,"azimuth":30.59,"magnitude":1.77},{"hr":4910,"altitude":50.78,"azimuth":127.27,"magnitude":3.38},{"hr":4915,"altitude":63.78,"azimuth":63.84,"magnitude":2.9},{"hr":4932,"altitude":54.66,"azimuth":116.2,"magnitude":2.83},{"hr":5020,"altitude":26.33,"azimuth":142.81,"magnitude":3.0},{"hr":5028,"altitude":14.81,"azimuth":150.43,"magnitude":2.75},{"hr":5054,"altitude":53.79,"azimuth":36.15,"magnitude":2.27},{"hr":5055,"altitude":53.79,"azimuth":36.16,"magnitude":3.95},{"hr":5056,"altitude":34.99,"azimuth":132.96,"magnitude":0.98},{"hr":5089,"altitude":11.43,"azimuth":150.16,"magnitude":3.88},{"hr":5107,"altitude":41.01,"azimuth":121.51,"magnitude":3.37},{"hr":5190,"altitude":7.54,"azimuth":148.6,"magnitude":3.41},{"hr":5191,"altitude":52.39,"azimuth":47.03,"magnitude":1.86},{"hr":5193,"altitude":6.9,"azimuth":149.06,"magnitude":3.04},{"hr":5231,"altitude":2.44,"azimuth":151.16,"magnitude":2.55},{"hr":5235,"altitude":47.58,"azimuth":95.52,"magnitude":2.68},{"hr":5248,"altitude":6.21,"azimuth":147.56,"magnitude":3.83},{"hr":5249,"altitude":4.05,"azimuth":149.2,"magnitude":3.87},{"hr":5287,"altitude":16.91,"azimuth":136.08,"magnitude":3.27},{"hr":5288,"altitude":9.59,"azimuth":142.6,"magnitude":2.06},{"hr":5291,"altitude":44.97,"azimuth":26.76,"magnitude":3.65},{"hr":5340,"altitude":43.39,"azimuth":91.58,"magnitude":-0.04},{"hr":5354,"altitude":0.74,"azimuth":147.24,"magnitude":3.55},{"hr":5429,"altitude":43.58,"azimuth":75.33,"magnitude":3.58},{"hr":5435,"altitude":44.99,"azimuth":64.41,"magnitude":3.03},{"hr":5440,"altitude":1.55,"azimuth":142.46,"magnitude":2.31},{"hr":5471,"altitude":3.71,"azimuth":138.46,"magnitude":4.0},{"hr":5487,"altitude":24.64,"azimuth":113.13,"magnitude":3.88},{"hr":5506,"altitude":39.89,"azimuth":78.49,"magnitude":2.7},{"hr":5511,"altitude":28.37,"azimuth":105.73,"magnitude":3.72},{"hr":5531,"altitude":16.7,"azimuth":120.58,"magnitude":2.75},{"hr":5563,"altitude":37.29,"azimuth":17.1,"magnitude":2.08},{"hr":5602,"altitude":39.55,"azimuth":60.56,"magnitude":3.5},{"hr":5603,"altitude":8.46,"azimuth":125.9,"magnitude":3.29},{"hr":5681,"altitude":35.38,"azimuth":68.53,"magnitude":3.47},{"hr":5685,"altitude":15.65,"azimuth":111.05,"magnitude":2.61},{"hr":5735,"altitude":35.85,"azimuth":20.65,"magnitude":3.05},{"hr":5744,"altitude":36.96,"azimuth":36.61,"magnitude":3.29},{"hr":5747,"altitude":31.59,"azimuth":72.38,"magnitude":3.68},{"hr":5787,"altitude":8.85,"azimuth":112.93,"magnitude":3.91},{"hr":5788,"altitude":22.44,"azimuth":90.66,"magnitude":3.8},{"hr":5789,"altitude":22.44,"azimuth":90.66,"magnitude":3.8},{"hr":5793,"altitude":29.33,"azimuth":74.37,"magnitude":2.23},{"hr":5794,"altitude":0.88,"azimuth":123.69,"magnitude":3.58},{"hr":5849,"altitude":27.5,"azimuth":74.05,"magnitude":3.84},{"hr":5854,"altitude":18.39,"azimuth":93.28,"magnitude":2.65},{"hr":5867,"altitude":22.25,"azimuth":84.59,"magnitude":3.67},{"hr":5881,"altitude":12.18,"azimuth":101.33,"magnitude":3.53},{"hr":5892,"altitude":16.01,"azimuth":94.19,"magnitude":3.71},{"hr":5933,"altitude":20.16,"azimuth":83.16,"magnitude":3.85},{"hr":5958,"altitude":23.89,"azimuth":72.82,"magnitude":2.0},{"hr":5984,"altitude":0.13,"azimuth":113.16,"magnitude":2.62},{"hr":6056,"altitude":6.78,"azimuth":98.29,"magnitude":2.74},{"hr":6075,"altitude":5.41,"azimuth":98.64,"magnitude":3.24},{"hr":6092,"altitude":27.03,"azimuth":50.03,"magnitude":3.89},{"hr":6095,"altitude":16.32,"azimuth":76.99,"magnitude":3.75},{"hr":6132,"altitude":29.83,"azimuth":33.01,"magnitude":2.74},{"hr":6148,"altitude":15.64,"azimuth":73.9,"magnitude":2.77},{"hr":6149,"altitude":6.1,"azimuth":91.25,"magnitude":3.82},{"hr":6212,"altitude":17.85,"azimuth":63.28,"magnitude":2.81},{"hr":6220,"altitude":20.52,"azimuth":56.05,"magnitude":3.53},{"hr":6299,"altitude":4.04,"azimuth":81.49,"magnitude":3.2},{"hr":6324,"altitude":13.9,"azimuth":62.05,"magnitude":3.92},{"hr":6396,"altitude":25.91,"azimuth":27.14,"magnitude":3.17},{"hr":6406,"altitude":2.97,"azimuth":75.05,"magnitude":3.48},{"hr":6410,"altitude":8.14,"azimuth":65.92,"magnitude":3.14},{"hr":6418,"altitude":13.88,"azimuth":55.22,"magnitude":3.16},{"hr":6536,"altitude":18.43,"azimuth":39.53,"magnitude":2.79},{"hr":6588,"altitude":14.21,"azimuth":44.64,"magnitude":3.8},{"hr":6623,"altitude":3.58,"azimuth":59.79,"magnitude":3.42},{"hr":6688,"altitude":17.55,"azimuth":33.65,"magnitude":3.75},{"hr":6695,"altitude":7.0,"azimuth":50.62,"magnitude":3.86},{"hr":6703,"altitude":2.34,"azimuth":57.15,"magnitude":3.7},{"hr":6705,"altitude":14.49,"azimuth":38.28,"magnitude":2.23},{"hr":6779,"altitude":0.3,"azimuth":56.35,"magnitude":3.83},{"hr":6927,"altitude":22.73,"azimuth":17.4,"magnitude":3.57},{"hr":7001,"altitude":1.4,"azimuth":44.68,"magnitude":0.03},{"hr":7310,"altitude":16.25,"azimuth":19.03,"magnitude":3.07},{"hr":7328,"altitude":5.96,"azimuth":29.17,"magnitude":3.77},{"hr":7420,"altitude":3.45,"azimuth":28.91,"magnitude":3.79},{"hr":7582,"altitude":15.88,"azimuth":14.76,"magnitude":3.83},{"hr":7957,"altitude":5.71,"azimuth":14.68,"magnitude":3.43},{"hr":8162,"altitude":4.8,"azimuth":10.82,"magnitude":2.44},{"hr":8238,"altitude":11.97,"azimuth":7.14,"magnitude":3.23},{"hr":8694,"altitude":6.45,"azimuth":0.48,"magnitude":3.52},{"hr":8974,"altitude":18.08,"azimuth":357.48,"magnitude":3.21}],"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]]}],"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"}},"night":{"phase":"night","sun_altitude":-40.34,"planets":[{"id":"sun","altitude":-40.34,"azimuth":1.31},{"id":"moon","altitude":-1.74,"azimuth":283.93},{"id":"mercury","altitude":-32.39,"azimuth":340.74},{"id":"venus","altitude":-27.8,"azimuth":328.88},{"id":"mars","altitude":-40.43,"azimuth":42.74},{"id":"jupiter","altitude":43.03,"azimuth":206.48},{"id":"saturn","altitude":-20.05,"azimuth":88.63}],"stars":[{"hr":21,"altitude":9.56,"azimuth":24.58,"magnitude":2.27},{"hr":153,"altitude":3.1,"azimuth":25.19,"magnitude":3.66},{"hr":168,"altitude":4.86,"azimuth":23.15,"magnitude":2.23},{"hr":219,"altitude":5.17,"azimuth":21.44,"magnitude":3.44},{"hr":264,"altitude":6.97,"azimuth":18.95,"magnitude":2.47},{"hr":403,"altitude":4.66,"azimuth":16.15,"magnitude":2.68},{"hr":424,"altitude":29.55,"azimuth":0.28,"magnitude":2.02},{"hr":542,"altitude":6.28,"azimuth":11.61,"magnitude":3.38},{"hr":580,"altitude":14.05,"azimuth":7.44,"magnitude":3.98},{"hr":3323,"altitude":18.09,"azimuth":330.69,"magnitude":3.36},{"hr":3569,"altitude":15.61,"azimuth":316.99,"magnitude":3.14},{"hr":3579,"altitude":12.82,"azimuth":311.2,"magnitude":3.97},{"hr":3594,"altitude":15.86,"azimuth":315.81,"magnitude":3.6},{"hr":3690,"altitude":13.54,"azimuth":304.98,"magnitude":3.82},{"hr":3705,"altitude":12.81,"azimuth":302.58,"magnitude":3.13},{"hr":3757,"altitude":25.62,"azimuth":329.9,"magnitude":3.67},{"hr":3775,"altitude":22.05,"azimuth":318.07,"magnitude":3.17},{"hr":3852,"altitude":4.93,"azimuth":278.62,"magnitude":3.52},{"hr":3873,"altitude":12.63,"azimuth":290.37,"magnitude":2.98},{"hr":3888,"altitude":26.79,"azimuth":324.87,"magnitude":3.8},{"hr":3905,"altitude":15.05,"azimuth":291.67,"magnitude":3.88},{"hr":3975,"altitude":13.83,"azimuth":281.58,"magnitude":3.52},{"hr":3982,"altitude":11.77,"azimuth":277.13,"magnitude":1.35},{"hr":4031,"altitude":18.79,"azimuth":286.74,"magnitude":3.44},{"hr":4033,"altitude":26.19,"azimuth":306.3,"magnitude":3.45},{"hr":4057,"altitude":17.9,"azimuth":283.0,"magnitude":2.61},{"hr":4058,"altitude":17.9,"azimuth":283.0,"magnitude":3.8},{"hr":4069,"altitude":26.68,"azimuth":304.46,"magnitude":3.05},{"hr":4094,"altitude":0.83,"azimuth":249.92,"magnitude":3.81},{"hr":4133,"altitude":15.75,"azimuth":271.75,"magnitude":3.85},{"hr":4232,"altitude":5.92,"azimuth":247.38,"magnitude":3.11},{"hr":4247,"altitude":30.27,"azimuth":294.44,"magnitude":3.83},{"hr":4295,"altitude":35.74,"azimuth":320.2,"magnitude":2.37},{"hr":4301,"altitude":35.99,"azimuth":326.83,"magnitude":1.79},{"hr":4335,"altitude":35.79,"azimuth":305.47,"magnitude":3.01},{"hr":4357,"altitude":29.69,"azimuth":277.77,"magnitude":2.56},{"hr":4359,"altitude":27.55,"azimuth":272.5,"magnitude":3.34},{"hr":4377,"altitude":34.94,"azimuth":291.35,"magnitude":3.48},{"hr":4382,"altitude":12.59,"azimuth":244.38,"magnitude":3.56},{"hr":4399,"altitude":27.37,"azimuth":266.38,"magnitude":3.94},{"hr":4434,"altitude":37.75,"azimuth":336.71,"magnitude":3.84},{"hr":4450,"altitude":4.45,"azimuth":228.86,"magnitude":3.54},{"hr":4518,"altitude":42.35,"azimuth":308.96,"magnitude":3.71},{"hr":4534,"altitude":34.68,"azimuth":267.2,"magnitude":2.14},{"hr":4540,"altitude":28.52,"azimuth":253.99,"magnitude":3.61},{"hr":4554,"altitude":43.28,"azimuth":317.1,"magnitude":2.44},{"hr":4630,"altitude":16.76,"azimuth":230.19,"magnitude":3.0},{"hr":4660,"altitude":45.68,"azimuth":322.49,"magnitude":3.31},{"hr":4662,"altitude":21.24,"azimuth":233.09,"magnitude":2.59},{"hr":4689,"altitude":33.04,"azimuth":246.74,"magnitude":3.89},{"hr":4757,"altitude":24.36,"azimuth":231.33,"magnitude":2.95},{"hr":4786,"altitude":20.08,"azimuth":225.39,"magnitude":2.65},{"hr":4787,"altitude":42.57,"azimuth":339.98,"magnitude":3.87},{"hr":4802,"altitude":1.17,"azimuth":208.43,"magnitude":3.86},{"hr":4819,"altitude":1.23,"azimuth":207.67,"magnitude":2.17},{"hr":4825,"altitude":36.78,"azimuth":241.83,"magnitude":3.65},{"hr":4826,"altitude":36.78,"azimuth":241.83,"magnitude":3.68},{"hr":4905,"altitude":51.06,"azimuth":323.59,"magnitude":1.77},{"hr":4910,"altitude":42.56,"azimuth":243.77,"magnitude":3.38},{"hr":4915,"altitude":55.22,"azimuth":294.77,"magnitude":2.9},{"hr":4932,"altitude":48.37,"azimuth":251.07,"magnitude":2.83},{"hr":5020,"altitude":26.58,"azimuth":216.79,"magnitude":3.0},{"hr":5028,"altitude":15.36,"azimuth":208.65,"magnitude":2.75},{"hr":5054,"altitude":55.3,"azimuth":325.31,"magnitude":2.27},{"hr":5055,"altitude":55.3,"azimuth":325.31,"magnitude":3.95},{"hr":5056,"altitude":37.21,"azimuth":223.62,"magnitude":0.98},{"hr":5089,"altitude":14.04,"azimuth":205.42,"magnitude":3.88},{"hr":5107,"altitude":46.89,"azimuth":230.19,"magnitude":3.37},{"hr":5132,"altitude":2.23,"azimuth":197.55,"magnitude":2.3},{"hr":5190,"altitude":13.58,"azimuth":201.14,"magnitude":3.41},{"hr":5191,"altitude":61.3,"azimuth":319.74,"magnitude":1.86},{"hr":5193,"altitude":12.87,"azimuth":200.78,"magnitude":3.04},{"hr":5231,"altitude":8.88,"azimuth":197.83,"magnitude":2.55},{"hr":5235,"altitude":63.07,"azimuth":250.07,"magnitude":2.68},{"hr":5248,"altitude":13.85,"azimuth":199.43,"magnitude":3.83},{"hr":5249,"altitude":11.39,"azimuth":198.29,"magnitude":3.87},{"hr":5287,"altitude":28.71,"azimuth":204.24,"magnitude":3.27},{"hr":5288,"altitude":19.78,"azimuth":200.11,"magnitude":2.06},{"hr":5291,"altitude":52.65,"azimuth":343.02,"magnitude":3.65},{"hr":5340,"altitude":67.68,"azimuth":245.26,"magnitude":-0.04},{"hr":5354,"altitude":11.47,"azimuth":194.37,"magnitude":3.55},{"hr":5429,"altitude":75.02,"azimuth":274.89,"magnitude":3.58},{"hr":5435,"altitude":73.62,"azimuth":304.12,"magnitude":3.03},{"hr":5440,"altitude":16.02,"azimuth":192.65,"magnitude":2.31},{"hr":5469,"altitude":11.22,"azimuth":190.22,"magnitude":2.3},{"hr":5471,"altitude":20.55,"azimuth":192.51,"magnitude":4.0},{"hr":5487,"altitude":51.47,"azimuth":203.72,"magnitude":3.88},{"hr":5506,"altitude":77.25,"azimuth":259.07,"magnitude":2.7},{"hr":5511,"altitude":58.78,"azimuth":207.35,"magnitude":3.72},{"hr":5531,"altitude":42.14,"azimuth":196.47,"magnitude":2.75},{"hr":5563,"altitude":45.63,"azimuth":355.12,"magnitude":2.08},{"hr":5571,"altitude":15.96,"azimuth":188.13,"magnitude":2.68},{"hr":5576,"altitude":16.99,"azimuth":188.19,"magnitude":3.13},{"hr":5602,"altitude":77.08,"azimuth":324.4,"magnitude":3.5},{"hr":5603,"altitude":33.75,"azimuth":190.17,"magnitude":3.29},{"hr":5646,"altitude":10.74,"azimuth":184.95,"magnitude":3.87},{"hr":5649,"altitude":7.4,"azimuth":184.52,"magnitude":3.41},{"hr":5681,"altitude":83.72,"azimuth":300.87,"magnitude":3.47},{"hr":5685,"altitude":49.94,"azimuth":189.37,"magnitude":2.61},{"hr":5695,"altitude":18.95,"azimuth":184.03,"magnitude":3.22},{"hr":5705,"altitude":23.33,"azimuth":184.31,"magnitude":3.56},{"hr":5708,"altitude":14.94,"azimuth":183.46,"magnitude":3.37},{"hr":5735,"altitude":48.32,"azimuth":357.62,"magnitude":3.05},{"hr":5744,"altitude":61.15,"azimuth":355.65,"magnitude":3.29},{"hr":5747,"altitude":86.85,"azimuth":249.51,"magnitude":3.68},{"hr":5776,"altitude":18.57,"azimuth":181.26,"magnitude":2.78},{"hr":5787,"altitude":44.94,"azimuth":182.01,"magnitude":3.91},{"hr":5788,"altitude":70.23,"azimuth":184.77,"magnitude":3.8},{"hr":5789,"altitude":70.23,"azimuth":184.77,"magnitude":3.8},{"hr":5793,"altitude":86.17,"azimuth":202.82,"magnitude":2.23},{"hr":5794,"altitude":31.6,"azimuth":181.14,"magnitude":3.58},{"hr":5812,"altitude":29.97,"azimuth":180.7,"magnitude":3.66},{"hr":5849,"altitude":86.03,"azimuth":175.42,"magnitude":3.84},{"hr":5854,"altitude":66.16,"azimuth":178.22,"magnitude":2.65},{"hr":5867,"altitude":75.13,"azimuth":175.45,"magnitude":3.67},{"hr":5881,"altitude":56.26,"azimuth":176.3,"magnitude":3.53},{"hr":5883,"altitude":26.08,"azimuth":177.8,"magnitude":3.95},{"hr":5892,"altitude":64.13,"azimuth":174.6,"magnitude":3.71},{"hr":5928,"altitude":30.42,"azimuth":176.09,"magnitude":3.88},{"hr":5933,"altitude":75.0,"azimuth":165.82,"magnitude":3.85},{"hr":5944,"altitude":33.48,"azimuth":175.31,"magnitude":2.89},{"hr":5948,"altitude":21.21,"azimuth":176.08,"magnitude":3.41},{"hr":5953,"altitude":36.93,"azimuth":174.54,"magnitude":2.32},{"hr":5958,"altitude":84.1,"azimuth":136.14,"magnitude":2.0},{"hr":5984,"altitude":39.61,"azimuth":172.66,"magnitude":2.62},{"hr":5993,"altitude":38.71,"azimuth":172.38,"magnitude":3.96},{"hr":6056,"altitude":55.15,"azimuth":165.51,"magnitude":2.74},{"hr":6075,"altitude":53.95,"azimuth":164.23,"magnitude":3.24},{"hr":6084,"altitude":33.34,"azimuth":169.26,"magnitude":2.89},{"hr":6092,"altitude":72.29,"azimuth":22.29,"magnitude":3.89},{"hr":6095,"altitude":75.59,"azimuth":138.06,"magnitude":3.75},{"hr":6132,"altitude":57.96,"azimuth":9.61,"magnitude":2.74},{"hr":6134,"altitude":32.16,"azimuth":167.3,"magnitude":0.96},{"hr":6148,"altitude":75.95,"azimuth":125.75,"magnitude":2.77},{"hr":6149,"altitude":59.38,"azimuth":155.11,"magnitude":3.82},{"hr":6165,"altitude":30.1,"azimuth":166.13,"magnitude":2.82},{"hr":6175,"altitude":47.03,"azimuth":159.67,"magnitude":2.56},{"hr":6212,"altitude":77.08,"azimuth":80.2,"magnitude":2.81},{"hr":6220,"altitude":74.68,"azimuth":51.44,"magnitude":3.53},{"hr":6241,"altitude":23.44,"azimuth":164.58,"magnitude":2.29},{"hr":6247,"altitude":19.74,"azimuth":165.34,"magnitude":3.08},{"hr":6252,"altitude":19.74,"azimuth":165.24,"magnitude":3.57},{"hr":6271,"altitude":15.45,"azimuth":166.09,"magnitude":3.62},{"hr":6285,"altitude":2.2,"azimuth":169.36,"magnitude":3.13},{"hr":6299,"altitude":62.56,"azimuth":135.59,"magnitude":3.2},{"hr":6324,"altitude":73.01,"azimuth":82.72,"magnitude":3.92},{"hr":6378,"altitude":39.27,"azimuth":151.93,"magnitude":2.43},{"hr":6380,"altitude":13.61,"azimuth":163.21,"magnitude":3.33},{"hr":6396,"altitude":52.09,"azimuth":14.47,"magnitude":3.17},{"hr":6406,"altitude":63.32,"azimuth":121.35,"magnitude":3.48},{"hr":6410,"altitude":68.58,"azimuth":98.95,"magnitude":3.14},{"hr":6418,"altitude":69.46,"azimuth":65.18,"magnitude":3.16},{"hr":6453,"altitude":29.71,"azimuth":153.68,"magnitude":3.27},{"hr":6461,"altitude":1.38,"azimuth":165.66,"magnitude":2.85},{"hr":6462,"altitude":0.59,"azimuth":165.97,"magnitude":3.34},{"hr":6508,"altitude":17.76,"azimuth":157.45,"magnitude":2.69},{"hr":6510,"altitude":6.2,"azimuth":162.53,"magnitude":2.95},{"hr":6527,"altitude":17.7,"azimuth":156.83,"magnitude":1.63},{"hr":6536,"altitude":60.24,"azimuth":34.41,"magnitude":2.79},{"hr":6553,"altitude":12.06,"azimuth":158.77,"magnitude":1.87},{"hr":6556,"altitude":58.34,"azimuth":117.82,"magnitude":2.08},{"hr":6561,"altitude":36.45,"azimuth":144.42,"magnitude":3.54},{"hr":6580,"altitude":15.2,"azimuth":156.07,"magnitude":2.41},{"hr":6588,"altitude":62.19,"azimuth":47.24,"magnitude":3.8},{"hr":6603,"altitude":51.42,"azimuth":125.72,"magnitude":2.77},{"hr":6615,"altitude":13.77,"azimuth":155.69,"magnitude":3.03},{"hr":6623,"altitude":62.6,"azimuth":87.45,"magnitude":3.42},{"hr":6629,"altitude":49.32,"azimuth":126.52,"magnitude":3.75},{"hr":6630,"altitude":16.28,"azimuth":153.78,"magnitude":3.21},{"hr":6688,"altitude":54.85,"azimuth":31.21,"magnitude":3.75},{"hr":6695,"altitude":61.26,"azimuth":66.84,"magnitude":3.86},{"hr":6698,"altitude":38.07,"azimuth":134.98,"magnitude":3.34},{"hr":6703,"altitude":60.48,"azimuth":83.26,"magnitude":3.7},{"hr":6705,"altitude":57.25,"azimuth":39.88,"magnitude":2.23},{"hr":6714,"altitude":47.21,"azimuth":122.92,"magnitude":3.97},{"hr":6743,"altitude":3.44,"azimuth":157.65,"magnitude":3.66},{"hr":6746,"altitude":20.28,"azimuth":147.22,"magnitude":2.99},{"hr":6771,"altitude":50.21,"azimuth":113.59,"magnitude":3.73},{"hr":6779,"altitude":58.29,"azimuth":83.38,"magnitude":3.83},{"hr":6812,"altitude":26.95,"azimuth":139.78,"magnitude":3.86},{"hr":6832,"altitude":13.63,"azimuth":148.72,"magnitude":3.11},{"hr":6859,"altitude":18.91,"azimuth":143.98,"magnitude":2.7},{"hr":6869,"altitude":39.45,"azimuth":123.79,"magnitude":3.26},{"hr":6879,"altitude":14.82,"azimuth":146.19,"magnitude":1.85},{"hr":6895,"altitude":52.75,"azimuth":93.34,"magnitude":3.84},{"hr":6897,"altitude":5.05,"azimuth":152.54,"magnitude":3.51},{"hr":6913,"altitude":21.5,"azimuth":139.84,"magnitude":2.81},{"hr":6927,"altitude":42.66,"azimuth":15.05,"magnitude":3.57},{"hr":6973,"altitude":33.24,"azimuth":125.53,"magnitude":3.85},{"hr":7001,"altitude":53.2,"azimuth":64.51,"magnitude":0.03},{"hr":7039,"altitude":17.77,"azimuth":137.65,"magnitude":3.17},{"hr":7106,"altitude":50.12,"azimuth":72.88,"magnitude":3.45},{"hr":7121,"altitude":16.86,"azimuth":135.48,"magnitude":2.02},{"hr":7150,"altitude":20.25,"azimuth":131.3,"magnitude":3.51},{"hr":7178,"altitude":48.17,"azimuth":73.42,"magnitude":3.24},{"hr":7194,"altitude":13.13,"azimuth":136.77,"magnitude":2.6},{"hr":7217,"altitude":18.67,"azimuth":130.55,"magnitude":3.77},{"hr":7234,"altitude":14.08,"azimuth":134.5,"magnitude":3.32},{"hr":7235,"altitude":40.41,"azimuth":97.63,"magnitude":2.99},{"hr":7236,"altitude":29.74,"azimuth":116.56,"magnitude":3.44},{"hr":7264,"altitude":18.33,"azimuth":129.13,"magnitude":2.89},{"hr":7310,"altitude":41.64,"azimuth":23.93,"magnitude":3.07},{"hr":7328,"altitude":45.04,"azimuth":43.09,"magnitude":3.77},{"hr":7340,"altitude":18.43,"azimuth":124.67,"magnitude":3.93},{"hr":7348,"altitude":2.41,"azimuth":141.19,"magnitude":3.97},{"hr":7377,"altitude":30.61,"azimuth":105.81,"magnitude":3.36},{"hr":7417,"altitude":40.38,"azimuth":77.53,"magnitude":3.08},{"hr":7420,"altitude":43.33,"azimuth":45.66,"magnitude":3.79},{"hr":7525,"altitude":30.09,"azimuth":95.28,"magnitude":2.72},{"hr":7528,"altitude":40.79,"azimuth":54.55,"magnitude":2.87},{"hr":7536,"altitude":33.42,"azimuth":86.82,"magnitude":3.82},{"hr":7557,"altitude":28.26,"azimuth":96.4,"magnitude":0.77},{"hr":7570,"altitude":23.82,"azimuth":103.63,"magnitude":3.9},{"hr":7582,"altitude":37.77,"azimuth":22.12,"magnitude":3.83},{"hr":7602,"altitude":26.06,"azimuth":98.16,"magnitude":3.71},{"hr":7615,"altitude":37.03,"azimuth":66.86,"magnitude":3.89},{"hr":7635,"altitude":31.36,"azimuth":84.47,"magnitude":3.47},{"hr":7710,"altitude":18.87,"azimuth":102.55,"magnitude":3.23},{"hr":7735,"altitude":36.01,"azimuth":51.83,"magnitude":3.79},{"hr":7751,"altitude":35.83,"azimuth":50.59,"magnitude":3.98},{"hr":7754,"altitude":10.97,"azimuth":111.69,"magnitude":3.57},{"hr":7776,"altitude":9.1,"azimuth":113.14,"magnitude":3.08},{"hr":7796,"altitude":33.27,"azimuth":59.23,"magnitude":2.2},{"hr":7882,"altitude":20.89,"azimuth":84.89,"magnitude":3.63},{"hr":7906,"altitude":21.04,"azimuth":83.39,"magnitude":3.77},{"hr":7924,"altitude":31.0,"azimuth":52.5,"magnitude":1.25},{"hr":7949,"altitude":26.88,"azimuth":64.59,"magnitude":2.46},{"hr":7950,"altitude":6.57,"azimuth":105.07,"magnitude":3.77},{"hr":7957,"altitude":32.88,"azimuth":33.07,"magnitude":3.43},{"hr":8028,"altitude":27.14,"azimuth":56.16,"magnitude":3.94},{"hr":8079,"altitude":26.61,"azimuth":52.72,"magnitude":3.72},{"hr":8115,"altitude":20.23,"azimuth":66.07,"magnitude":3.2},{"hr":8130,"altitude":22.9,"azimuth":58.15,"magnitude":3.72},{"hr":8131,"altitude":8.14,"azimuth":88.68,"magnitude":3.92},{"hr":8162,"altitude":29.12,"azimuth":31.66,"magnitude":2.44},{"hr":8238,"altitude":29.4,"azimuth":22.45,"magnitude":3.23},{"hr":8308,"altitude":4.34,"azimuth":81.12,"magnitude":2.39},{"hr":8430,"altitude":7.37,"azimuth":64.93,"magnitude":3.76},{"hr":8465,"altitude":21.7,"azimuth":34.25,"magnitude":3.35},{"hr":8571,"altitude":19.6,"azimuth":32.98,"magnitude":3.75},{"hr":8585,"altitude":15.55,"azimuth":40.39,"magnitude":3.77},{"hr":8650,"altitude":3.16,"azimuth":56.57,"magnitude":2.94},{"hr":8694,"altitude":21.01,"azimuth":24.43,"magnitude":3.52},{"hr":8762,"altitude":6.85,"azimuth":44.38,"magnitude":3.62},{"hr":8961,"altitude":4.37,"azimuth":37.18,"magnitude":3.82},{"hr":8974,"altitude":23.64,"azimuth":11.77,"magnitude":3.21}],"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]]}],"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"}}}} diff --git a/scripts/birth_sky.py b/scripts/birth_sky.py new file mode 100644 index 00000000..0b15a826 --- /dev/null +++ b/scripts/birth_sky.py @@ -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), + ) diff --git a/scripts/build_bright_stars.py b/scripts/build_bright_stars.py new file mode 100644 index 00000000..27a20d3c --- /dev/null +++ b/scripts/build_bright_stars.py @@ -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") diff --git a/scripts/data/bright_stars.json b/scripts/data/bright_stars.json new file mode 100644 index 00000000..90273b5c --- /dev/null +++ b/scripts/data/bright_stars.json @@ -0,0 +1 @@ +{"source":"Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), VizieR V/50","source_url":"https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz","source_sha256":"3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed","license":"Public domain (NASA Astronomical Data Center product, redistributed by CDS without restriction)","selection":"V magnitude <= 4.0; 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":[{"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]]}],"stars":[[15,2.09708,29.09056,2.06],[21,2.29458,59.14972,2.27],[25,2.35292,-45.7475,3.88],[39,3.30917,15.18361,2.83],[74,4.85708,-8.82389,3.56],[98,6.43792,-77.25417,2.8],[99,6.57083,-42.30611,2.39],[100,6.55083,-43.68,3.94],[153,9.24292,53.89694,3.66],[165,9.83208,30.86083,3.27],[168,10.12708,56.53722,2.23],[188,10.8975,-17.98667,2.04],[219,12.275,57.81583,3.44],[264,14.17708,60.71667,2.47],[269,14.18833,38.49944,3.87],[322,16.52083,-46.71861,3.31],[334,17.1475,-10.18222,3.45],[337,17.43292,35.62056,2.06],[338,17.09625,-55.24583,3.92],[402,21.00583,-8.18333,3.6],[403,21.45417,60.23528,2.68],[424,37.95292,89.26417,2.02],[429,22.09125,-43.31833,3.41],[437,22.87083,15.34583,3.62],[440,22.81292,-49.07278,3.95],[464,24.49833,48.62833,3.57],[472,24.42875,-57.23667,0.46],[509,26.01708,-15.9375,3.5],[539,27.865,-10.335,3.73],[542,28.59875,63.67,3.38],[544,28.27042,29.57889,3.41],[553,28.66,20.80806,2.64],[566,28.98958,-51.60889,3.7],[580,30.85875,72.42139,3.98],[585,30.00125,-21.07778,4.0],[591,29.6925,-61.56972,2.86],[603,30.975,42.32972,2.26],[617,31.79333,23.4625,2.0],[622,32.38583,34.98722,3.0],[674,34.1275,-51.51222,3.56],[681,34.83625,-2.9775,3.04],[804,40.825,3.23583,3.47],[834,42.67417,55.89556,3.76],[838,42.49583,27.26056,3.63],[854,43.56458,52.7625,3.95],[868,43.47042,-49.89028,4.0],[874,44.10708,-8.89806,3.89],[897,44.56542,-40.30472,3.24],[911,45.57,4.08972,2.53],[915,46.19917,53.50639,2.93],[921,46.29417,38.84028,3.39],[936,47.04208,40.95556,2.12],[941,47.37417,44.85722,3.8],[963,48.01792,-28.98694,3.87],[1003,49.87917,-21.75778,3.69],[1017,51.08083,49.86111,1.79],[1030,51.20333,9.02889,3.6],[1038,51.7925,9.73278,3.74],[1084,53.2325,-9.45833,3.73],[1122,55.73125,47.7875,3.01],[1131,56.07958,32.28833,3.83],[1135,56.29833,42.57861,3.77],[1136,55.81208,-9.76333,3.54],[1142,56.21875,24.11333,3.7],[1149,56.45667,24.36778,3.87],[1165,56.87125,24.105,2.87],[1175,56.05,-64.80694,3.85],[1178,57.29042,24.05333,3.63],[1203,58.53292,31.88361,2.85],[1208,56.80958,-74.23889,3.24],[1220,59.46333,40.01028,2.89],[1231,59.5075,-13.50861,2.95],[1239,60.17,12.49028,3.47],[1251,60.78917,5.98917,3.91],[1326,63.50042,-42.29444,3.86],[1336,63.60625,-62.47389,3.35],[1346,64.94833,15.6275,3.65],[1347,64.47375,-33.79833,3.56],[1373,65.73375,17.5425,3.76],[1393,66.00917,-34.01694,3.96],[1409,67.15417,19.18028,3.53],[1411,67.14375,15.96222,3.84],[1412,67.16542,15.87083,3.4],[1457,68.98,16.50917,0.85],[1463,69.07958,-3.3525,3.93],[1464,68.8875,-30.56222,3.82],[1465,68.49917,-55.045,3.27],[1481,69.545,-14.30389,3.87],[1543,72.46,6.96139,3.19],[1552,72.80167,5.605,3.69],[1567,73.56292,2.44056,3.72],[1577,74.24833,33.16611,2.69],[1605,75.49208,43.82333,2.99],[1612,75.61958,41.07583,3.75],[1641,76.62875,41.23444,3.17],[1654,76.36542,-22.37111,3.19],[1666,76.9625,-5.08639,2.79],[1702,78.23292,-16.20556,3.31],[1708,79.1725,45.99806,0.08],[1713,78.63458,-8.20167,0.12],[1735,79.40167,-6.84444,3.6],[1788,81.11917,-2.39694,3.36],[1790,81.28292,6.34972,1.64],[1791,81.57292,28.6075,1.65],[1829,82.06125,-20.75944,2.84],[1852,83.00167,-0.29917,2.23],[1862,82.80292,-35.47056,3.87],[1865,83.1825,-17.82222,2.58],[1879,83.78458,9.93417,3.54],[1899,83.85833,-5.91,2.77],[1903,84.05333,-1.20194,1.7],[1910,84.41125,21.1425,3.0],[1922,83.40625,-62.48972,3.76],[1931,84.68667,-2.6,3.81],[1948,85.18958,-1.94278,2.05],[1956,84.91208,-34.07417,2.64],[1983,86.11583,-22.44833,3.6],[1998,86.73875,-14.82194,3.55],[2004,86.93917,-9.66972,2.06],[2012,87.8725,39.14861,3.97],[2020,86.82125,-51.06639,3.85],[2035,87.83042,-20.87917,3.81],[2040,87.74,-35.76833,3.12],[2061,88.79292,7.40694,0.5],[2077,89.88167,54.28472,3.72],[2085,89.10125,-14.16778,3.71],[2088,89.88208,44.9475,1.9],[2095,89.93042,37.2125,2.62],[2120,89.78667,-42.81528,3.96],[2216,93.71917,22.50667,3.28],[2227,93.71375,-6.27472,3.98],[2282,95.07833,-30.06333,3.02],[2286,95.74,22.51361,2.88],[2294,95.675,-17.95583,1.98],[2296,95.52833,-33.43639,3.85],[2326,95.98792,-52.69583,-0.72],[2421,99.42792,16.39917,1.93],[2429,99.17083,-19.25583,3.95],[2451,99.44042,-43.19611,3.17],[2473,100.98292,25.13111,2.98],[2484,101.3225,12.89556,3.36],[2491,101.28708,-16.71611,-1.46],[2538,102.46042,-32.50861,3.96],[2540,103.19708,33.96111,3.6],[2550,102.0475,-61.94139,3.27],[2553,102.48417,-50.61472,2.93],[2580,103.53292,-24.18389,3.87],[2618,104.65625,-28.97222,1.5],[2646,105.42958,-27.93472,3.47],[2650,106.02708,20.57028,3.79],[2653,105.75625,-23.83333,3.02],[2693,107.09792,-26.39333,1.84],[2736,107.18708,-70.49889,3.78],[2749,108.70292,-26.77278,3.85],[2763,109.52333,16.54028,3.58],[2773,109.28583,-37.0975,2.7],[2777,110.03083,21.98222,3.53],[2803,109.2075,-67.95722,3.98],[2821,111.43167,27.79806,3.79],[2827,111.02375,-29.30306,2.45],[2845,111.7875,8.28944,2.9],[2878,112.3075,-43.30139,3.25],[2890,113.65,31.88861,2.88],[2891,113.65,31.88833,1.98],[2943,114.82542,5.225,0.38],[2970,115.31167,-9.55111,3.93],[2985,116.11167,24.39806,3.57],[2990,116.32875,28.02611,1.14],[2996,115.95208,-28.95472,3.96],[3017,116.31375,-37.96861,3.61],[3024,115.455,-72.60611,3.95],[3045,117.32375,-24.85972,3.34],[3080,118.05417,-40.57583,3.73],[3117,119.19458,-52.98222,3.47],[3165,120.89625,-40.00333,2.25],[3185,121.88583,-24.30417,2.81],[3207,122.38333,-47.33667,1.78],[3249,124.12875,9.18556,3.52],[3307,125.62833,-59.50972,1.86],[3314,126.415,-3.90639,3.9],[3323,127.56625,60.71806,3.36],[3347,126.43417,-66.13694,3.77],[3438,130.02583,-35.30833,3.97],[3445,130.15667,-46.64889,3.84],[3447,130.07333,-52.92194,3.62],[3461,131.17125,18.15417,3.94],[3468,130.89792,-33.18639,3.68],[3482,131.69417,6.41889,3.38],[3485,131.17583,-54.70833,1.96],[3487,131.50708,-46.04167,3.91],[3547,133.84833,5.94556,3.11],[3569,134.80167,48.04167,3.14],[3571,133.76167,-60.64472,3.84],[3579,135.16,41.78278,3.97],[3594,135.90625,47.15667,3.6],[3614,136.03875,-47.09778,3.75],[3615,135.61167,-66.39611,4.0],[3634,136.99917,-43.4325,2.21],[3659,137.74167,-58.96694,3.44],[3663,137.81917,-62.31722,3.97],[3665,138.59125,2.31417,3.88],[3685,138.3,-69.71722,1.68],[3690,139.71125,36.8025,3.82],[3699,139.2725,-59.27528,2.25],[3705,140.26375,34.3925,3.13],[3734,140.52833,-55.01083,2.5],[3748,141.89667,-8.65861,1.98],[3757,142.88208,63.06194,3.67],[3775,143.21417,51.67722,3.17],[3786,142.675,-40.46667,3.6],[3803,142.80542,-57.03444,3.13],[3845,144.96417,-1.14278,3.91],[3852,145.2875,9.89222,3.52],[3873,146.46292,23.77417,2.98],[3884,146.31167,-62.50778,3.69],[3888,147.7475,59.03861,3.8],[3890,146.77542,-65.07194,3.01],[3905,148.19083,26.00694,3.88],[3940,149.21583,-54.56778,3.54],[3975,151.83333,16.76278,3.52],[3982,152.09292,11.96722,1.35],[3994,152.64708,-12.35417,3.61],[4023,153.68417,-42.12194,3.85],[4031,154.1725,23.41722,3.44],[4033,154.27417,42.91444,3.45],[4037,153.43417,-70.03806,3.32],[4050,154.27083,-61.33222,3.4],[4057,154.99292,19.84167,2.61],[4058,154.99417,19.84056,3.8],[4069,155.58208,41.49944,3.05],[4094,156.5225,-16.83639,3.81],[4102,156.09875,-74.03167,4.0],[4114,156.96958,-58.73944,3.82],[4133,158.20292,9.30667,3.85],[4140,158.00583,-61.68528,3.32],[4167,159.32542,-48.22583,3.84],[4199,160.73917,-64.39444,2.76],[4216,161.6925,-49.42,2.69],[4232,162.40625,-16.19361,3.11],[4247,163.32792,34.215,3.83],[4257,163.37333,-58.85333,3.78],[4295,165.46042,56.3825,2.37],[4301,165.93208,61.75083,1.79],[4335,167.41583,44.49861,3.01],[4337,167.1475,-58.975,3.91],[4357,168.52708,20.52361,2.56],[4359,168.56,15.42944,3.34],[4377,169.61958,33.09417,3.48],[4382,169.83542,-14.77861,3.56],[4390,170.25167,-54.49111,3.89],[4399,170.98125,10.52917,3.94],[4434,172.85083,69.33111,3.84],[4450,173.25042,-31.85778,3.54],[4467,173.945,-63.01972,3.13],[4518,176.5125,47.77944,3.71],[4520,176.40167,-66.72861,3.64],[4534,177.265,14.57194,2.14],[4540,177.67375,1.76472,3.61],[4554,178.4575,53.69472,2.44],[4621,182.08958,-50.7225,2.6],[4630,182.53125,-22.61972,3.0],[4638,182.91292,-52.36861,3.96],[4656,183.78625,-58.74889,2.8],[4660,183.85667,57.0325,3.31],[4662,183.95167,-17.54194,2.59],[4689,184.97667,-0.66694,3.89],[4700,185.34,-60.40111,3.59],[4730,186.64958,-63.09917,1.33],[4731,186.65208,-63.09944,1.73],[4743,187.01,-50.23056,3.91],[4757,187.46625,-16.51556,2.95],[4763,187.79125,-57.11333,1.63],[4773,188.11667,-72.13306,3.87],[4786,188.59667,-23.39667,2.65],[4787,188.37083,69.78833,3.87],[4798,189.29583,-69.13556,2.69],[4802,189.42583,-48.54111,3.86],[4819,190.37917,-48.95972,2.17],[4825,190.415,-1.44944,3.65],[4826,190.415,-1.44944,3.68],[4844,191.57042,-68.10806,3.05],[4853,191.93,-59.68861,1.25],[4905,193.50708,55.95972,1.77],[4910,193.90083,3.3975,3.38],[4915,194.00708,38.31833,2.9],[4923,195.5675,-71.54889,3.62],[4932,195.54417,10.95917,2.83],[5020,199.73042,-23.17167,3.0],[5028,200.14917,-36.71222,2.75],[5054,200.98125,54.92528,2.27],[5055,200.985,54.92167,3.95],[5056,201.29833,-11.16139,0.98],[5089,202.76125,-39.4075,3.88],[5107,203.67333,-0.59583,3.37],[5132,204.97167,-53.46639,2.3],[5190,207.37625,-41.68778,3.41],[5191,206.885,49.31333,1.86],[5193,207.40417,-42.47389,3.04],[5231,208.885,-47.28833,2.55],[5235,208.67125,18.39778,2.68],[5248,209.56792,-42.10083,3.83],[5249,209.67,-44.80361,3.87],[5267,210.95583,-60.37306,0.61],[5287,211.59292,-26.6825,3.27],[5288,211.67083,-36.37,2.06],[5291,211.09708,64.37583,3.65],[5340,213.91542,19.1825,-0.04],[5354,214.85083,-46.05778,3.55],[5429,217.9575,30.37139,3.58],[5435,218.01958,38.30833,3.03],[5440,218.87667,-42.15778,2.31],[5459,219.89958,-60.83528,-0.01],[5460,219.90042,-60.83556,1.33],[5463,220.62667,-64.97528,3.19],[5469,220.4825,-47.38833,2.3],[5470,221.965,-79.04472,3.83],[5471,220.49,-37.79361,4.0],[5487,220.765,-5.65833,3.88],[5506,221.24667,27.07417,2.7],[5511,221.56208,1.89278,3.72],[5531,222.71958,-16.04167,2.75],[5563,222.67625,74.15556,2.08],[5571,224.63292,-43.13389,2.68],[5576,224.79042,-42.10417,3.13],[5602,225.48667,40.39056,3.5],[5603,226.0175,-25.28194,3.29],[5646,227.98375,-48.73778,3.87],[5649,228.07125,-52.09917,3.41],[5671,229.7275,-68.67944,2.89],[5681,228.87583,33.31472,3.47],[5685,229.25167,-9.38306,2.61],[5695,230.34292,-40.6475,3.22],[5705,230.45167,-36.26139,3.56],[5708,230.67042,-44.68944,3.37],[5735,230.18208,71.83389,3.05],[5744,231.2325,58.96611,3.29],[5747,231.95708,29.10583,3.68],[5776,233.78542,-41.16694,2.78],[5787,233.88167,-14.78944,3.91],[5788,233.70042,10.5375,3.8],[5789,233.70042,10.53917,3.8],[5793,233.67208,26.71472,2.23],[5794,234.25625,-28.135,3.58],[5812,234.66417,-29.77778,3.66],[5849,235.68583,26.29556,3.84],[5854,236.06708,6.42556,2.65],[5867,236.54708,15.42194,3.67],[5881,237.405,-3.43028,3.53],[5883,237.73958,-33.62722,3.95],[5892,237.70417,4.47778,3.71],[5897,238.78542,-63.43056,2.85],[5928,239.22125,-29.21417,3.88],[5933,239.11333,15.66167,3.85],[5944,239.71292,-26.11417,2.89],[5948,240.03042,-38.39694,3.41],[5953,240.08333,-22.62167,2.32],[5958,239.87583,25.92028,2.0],[5984,241.35917,-19.80556,2.62],[5993,241.70167,-20.66917,3.96],[6030,243.85958,-63.68556,3.85],[6056,243.58625,-3.69444,2.74],[6075,244.58042,-4.6925,3.24],[6084,245.29708,-25.59278,2.89],[6092,244.935,46.31333,3.89],[6095,245.48,19.15306,3.75],[6102,248.3625,-78.89722,3.89],[6132,245.99792,61.51417,2.74],[6134,247.35167,-26.43194,0.96],[6148,247.555,21.48972,2.77],[6149,247.72833,1.98389,3.82],[6165,248.97083,-28.21611,2.82],[6175,249.28958,-10.56722,2.56],[6212,250.32167,31.60306,2.81],[6217,252.16625,-69.02778,1.92],[6220,250.72417,38.92222,3.53],[6229,252.44625,-59.04139,3.76],[6241,252.54083,-34.29333,2.29],[6247,252.9675,-38.0475,3.08],[6252,253.08375,-38.0175,3.57],[6271,253.64583,-42.36139,3.62],[6285,254.655,-55.99028,3.13],[6299,254.41708,9.375,3.2],[6324,255.0725,30.92639,3.92],[6378,257.59458,-15.72472,2.43],[6380,258.03833,-43.23917,3.33],[6396,257.19667,65.71472,3.17],[6406,258.66208,14.39028,3.48],[6410,258.75792,24.83917,3.14],[6418,258.76167,36.80917,3.16],[6453,260.5025,-24.99944,3.27],[6461,261.325,-55.53,2.85],[6462,261.34833,-56.3775,3.34],[6500,262.77458,-60.68389,3.62],[6508,262.69083,-37.29583,2.69],[6510,262.96042,-49.87611,2.95],[6527,263.40208,-37.10389,1.63],[6536,262.60833,52.30139,2.79],[6553,264.33,-42.99778,1.87],[6556,263.73375,12.56,2.08],[6561,264.39667,-15.39861,3.54],[6580,265.62208,-39.03,2.41],[6582,266.43333,-64.72389,3.62],[6588,264.86625,46.00639,3.8],[6603,265.86833,4.56722,2.77],[6615,266.89625,-40.12694,3.03],[6623,266.61458,27.72056,3.42],[6629,266.97333,2.70722,3.75],[6630,267.46458,-37.04333,3.21],[6688,268.38208,56.87278,3.75],[6695,269.06333,37.25056,3.86],[6698,269.75667,-9.77361,3.34],[6703,269.44125,29.24778,3.7],[6705,269.15167,51.48889,2.23],[6714,270.16125,2.93167,3.97],[6743,271.65792,-50.09167,3.66],[6746,271.45208,-30.42417,2.99],[6771,271.8375,9.56389,3.73],[6779,271.88583,28.7625,3.83],[6812,273.44083,-21.05889,3.86],[6832,274.40667,-36.76167,3.11],[6859,275.24875,-29.82806,2.7],[6869,275.3275,-2.89889,3.26],[6879,276.04292,-34.38472,1.85],[6895,275.92458,21.76972,3.84],[6897,276.74333,-45.96833,3.51],[6913,276.9925,-25.42167,2.81],[6927,275.26417,72.73278,3.57],[6973,278.80167,-8.24417,3.85],[7001,279.23458,38.78361,0.03],[7039,281.41417,-26.99083,3.17],[7106,282.52,33.36278,3.45],[7121,283.81625,-26.29667,2.02],[7150,284.4325,-21.10667,3.51],[7178,284.73583,32.68944,3.24],[7194,285.65292,-29.88028,2.6],[7217,286.17083,-21.74167,3.77],[7234,286.735,-27.67056,3.32],[7235,286.3525,13.86333,2.99],[7236,286.56208,-4.8825,3.44],[7264,287.44083,-21.02361,2.89],[7310,288.13875,67.66167,3.07],[7328,289.27583,53.36861,3.77],[7340,290.41833,-17.84722,3.93],[7348,290.97167,-40.61611,3.97],[7377,291.37458,3.11472,3.36],[7417,292.68042,27.95972,3.08],[7420,292.42625,51.72972,3.79],[7525,296.565,10.61333,2.72],[7528,296.24375,45.13083,2.87],[7536,296.84708,18.53417,3.82],[7557,297.69583,8.86833,0.77],[7570,298.11833,1.00556,3.9],[7582,297.04333,70.26778,3.83],[7590,300.14792,-72.91056,3.96],[7602,298.82833,6.40667,3.71],[7615,299.07667,35.08333,3.89],[7635,299.68917,19.49222,3.47],[7665,302.18167,-66.18194,3.56],[7710,302.82625,-0.82139,3.23],[7735,303.40792,46.74139,3.79],[7751,303.86792,47.71444,3.98],[7754,304.51375,-12.54472,3.57],[7776,305.25292,-14.78139,3.08],[7790,306.41208,-56.735,1.94],[7796,305.55708,40.25667,2.2],[7869,309.39167,-47.29139,3.11],[7882,309.3875,14.59528,3.63],[7906,309.90958,15.91194,3.77],[7913,311.23958,-66.20306,3.42],[7924,310.35792,45.28028,1.25],[7949,311.55292,33.97028,2.46],[7950,311.91917,-9.49583,3.77],[7957,311.3225,61.83889,3.43],[7986,313.7025,-58.45417,3.65],[8028,314.29333,41.16722,3.94],[8079,316.23292,43.92778,3.72],[8115,318.23417,30.22694,3.2],[8130,318.69792,38.04556,3.72],[8131,318.95583,5.24778,3.92],[8162,319.645,62.58556,2.44],[8204,321.66667,-22.41139,3.74],[8232,322.88958,-5.57111,2.91],[8238,322.165,70.56083,3.23],[8254,325.36875,-77.39,3.76],[8278,325.02292,-16.66222,3.68],[8308,326.04667,9.875,2.39],[8322,326.76,-16.12722,2.87],[8353,328.48208,-37.365,3.01],[8414,331.44583,-0.31972,2.96],[8425,332.05833,-46.96111,1.74],[8430,331.75292,25.345,3.76],[8450,332.55,6.19778,3.53],[8465,332.71375,58.20111,3.35],[8502,334.62542,-60.25972,2.86],[8518,335.41417,-1.38722,3.84],[8556,337.3175,-43.49556,3.97],[8571,337.29292,58.41528,3.75],[8585,337.82292,50.2825,3.77],[8634,340.36542,10.83139,3.4],[8636,340.66708,-46.88472,2.1],[8650,340.75042,30.22139,2.94],[8667,341.63292,23.56556,3.95],[8675,342.13875,-51.31694,3.49],[8684,342.50083,24.60167,3.48],[8694,342.42,66.20056,3.52],[8698,343.15375,-7.57972,3.74],[8709,343.6625,-15.82083,3.27],[8728,344.41292,-29.62222,1.16],[8762,345.48042,42.32611,3.62],[8775,345.94375,28.08278,2.42],[8781,346.19042,15.20528,2.49],[8812,347.36167,-21.1725,3.66],[8820,347.59,-45.24667,3.9],[8848,349.3575,-58.23583,3.99],[8852,349.29125,3.28222,3.69],[8892,350.7425,-20.10056,3.97],[8961,354.39125,46.45806,3.82],[8974,354.83667,77.6325,3.21]]} diff --git a/scripts/generate_birth_sky_golden.py b/scripts/generate_birth_sky_golden.py new file mode 100644 index 00000000..5712edb0 --- /dev/null +++ b/scripts/generate_birth_sky_golden.py @@ -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() diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 04f86f45..22c34949 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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) diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 3a105726..841577f5 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -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. diff --git a/tests/test_birth_sky.py b/tests/test_birth_sky.py new file mode 100644 index 00000000..03311522 --- /dev/null +++ b/tests/test_birth_sky.py @@ -0,0 +1,201 @@ +"""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