From c35e0195e1e334ae0b4c41d83a33ee65217c4ad2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 28 Sep 2026 15:38:24 +0800 Subject: [PATCH] fix(chart): Sripati Bhava from the real sidereal MC; Bhava chart and tables (T5, BUG-1077) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- frontend/src/app/globals.css | 3 + .../components/chart-page/chart-page-view.tsx | 3 +- .../components/chart-page/chart-vedic-tab.tsx | 71 +++++ frontend/src/hooks/use-chart-page.ts | 1 + frontend/src/lib/chart-view-contract.ts | 32 ++ frontend/src/lib/chart-view-engine.ts | 2 +- frontend/src/lib/chart-view-labels.ts | 1 + frontend/src/lib/chart-view-load.ts | 13 +- frontend/src/lib/chart-view-mapper.ts | 91 +++++- frontend/src/lib/secondary-page-data.ts | 1 + frontend/tests/chart-bhava.test.tsx | 115 +++++++ frontend/tests/chart-page-hook.test.ts | 9 +- .../fixtures/chart-view-bhava-golden.json | 294 ++++++++++++++++++ scripts/bhava_chalit_mc.py | 62 ++++ scripts/jyotish_api_server.py | 2 +- tests/test_api_server_security.py | 52 ++++ 16 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/chart-bhava.test.tsx create mode 100644 frontend/tests/fixtures/chart-view-bhava-golden.json create mode 100644 scripts/bhava_chalit_mc.py diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index cb4f0697..a607a9ef 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5091,6 +5091,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-table-note { margin: 0 0 var(--space-2); } /* A note + table pair is one grid item, so the table keeps its own scroll box. */ .chart-page-varga-table-block { min-width: 0; } +.chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); } +/* A body the Bhava boundaries move to another house. */ +.chart-page-planet-table tr.is-shifted { background: var(--color-selected); } .chart-page-waiting-plot { width: min(100%, 400px); min-width: 0; diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 9fe86d5e..aeb6d2cb 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -37,7 +37,8 @@ export function ChartPageRoute() { * the natal packet that is already on the page, so they request nothing. */ function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null { - if (id === "D1" || id === "MOON" || id === "BHAVA") return null; + if (id === "D1" || id === "MOON") return null; + if (id === "BHAVA") return "bhava"; if (id === "TRANSIT") return "transit"; return "varga"; } diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 077215a3..0b05a2eb 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -6,6 +6,7 @@ import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; import type { ChartViewFailureStatus, ChartViewAscendantRow, + ChartViewBhavaOk, ChartViewOk, ChartViewPlanetRow, ChartViewTransitOk, @@ -236,6 +237,64 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { ); } +function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { + return ( + <> +
+ + + + + + + + + + + + + {bhava.rows.map((row) => { + const key = planetGlyphKey(row.name); + return ( + + + + + + + + ); + })} + +
Bhava · {bhava.houseSystem}
行星黄道经度星座宫Bhava 宫换宫
+ + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)}{row.rashiHouse}宫{row.bhavaHouse}宫{row.shifted ? "是" : "否"}
+
+
+ + + + + + + + + + + {bhava.cusps.map((cusp) => ( + + + + + + ))} + +
宫头与宫界
宫宫头起点(sandhi)
{cusp.house}宫{cusp.signLabel} {formatDegreeMinutes(cusp.degreeInSign)}{cusp.sandhiSignLabel} {formatDegreeMinutes(cusp.sandhiDegreeInSign)}
+
+ + ); +} + /** Chart, parameter table and boundary line, then the chart type's own table. */ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: { view: ChartViewOk; @@ -287,6 +346,18 @@ function ChartTypeBody({ view, vargaId, pending, failure }: { ); } + if (vargaId === "BHAVA") { + const bhava = view.bhava; + if (bhava.status !== "ok") return ; + return ( + +
+

{CHART_VIEW_COPY.bhavaNote}

+ +
+
+ ); + } if (vargaId === "TRANSIT") { const transit = view.transit; if (transit.status !== "ok") return ; diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 44a1c173..2bced290 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -48,6 +48,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie ...(layer === "varga" ? { vedic: incoming.vedic } : {}), ...(layer === "chara" ? { dasha: incoming.dasha } : {}), ...(layer === "western" ? { western: incoming.western } : {}), + ...(layer === "bhava" ? { bhava: incoming.bhava } : {}), ...(layer === "transit" ? { transit: incoming.transit } : {}), }; } diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 3e90a873..0a6aeefe 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -143,6 +143,35 @@ export const chartViewTransitOkSchema = z.object({ })).min(1), }); +/** + * Sripati Bhava Chalit: the natal bodies re-housed by the unequal house + * boundaries (sandhi) built from the birth ascendant and the sidereal MC. + */ +export const chartViewBhavaOkSchema = z.object({ + status: z.literal("ok"), + houseSystem: z.literal("Sripati"), + chart: northIndianChartSchema, + rows: z.array(z.object({ + name: z.string().min(1), + label: z.string().min(1), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + rashiHouse: z.number().int().min(1).max(12), + bhavaHouse: z.number().int().min(1).max(12), + shifted: z.boolean(), + })).min(1), + cusps: z.array(z.object({ + house: z.number().int().min(1).max(12), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + sandhiSign: z.string().min(1), + sandhiSignLabel: z.string().min(1), + sandhiDegreeInSign: z.number(), + })).length(12), +}); + export const chartViewPeriodSchema = z.object({ lord: z.string().min(1), label: z.string().min(1), @@ -248,6 +277,7 @@ export const chartViewOkSchema = z.object({ varga: chartViewFailureStatusSchema.optional(), chara: chartViewFailureStatusSchema.optional(), western: chartViewFailureStatusSchema.optional(), + bhava: chartViewFailureStatusSchema.optional(), transit: chartViewFailureStatusSchema.optional(), }).optional(), status: z.literal("ok"), @@ -270,6 +300,7 @@ export const chartViewOkSchema = z.object({ dualTrackNote: z.string().min(1), }), western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]), + bhava: z.union([chartViewBhavaOkSchema, chartViewUnavailableLayerSchema]), transit: z.union([chartViewTransitOkSchema, chartViewUnavailableLayerSchema]), accountId: z.string().min(1).optional(), profileFingerprint: z.string().min(1).optional(), @@ -298,6 +329,7 @@ export type ChartViewResponse = z.infer; export type ChartViewUnavailableLayer = z.infer; export type ChartViewWesternOk = z.infer; export type ChartViewTransitOk = z.infer; +export type ChartViewBhavaOk = z.infer; export type ChartViewMoonChart = z.infer; export type ChartViewVarga = z.infer; export type ChartViewVargaRow = z.infer; diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts index cd80c09d..418c245a 100644 --- a/frontend/src/lib/chart-view-engine.ts +++ b/frontend/src/lib/chart-view-engine.ts @@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000; export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000; const ENGINE_CACHE_MAX_ENTRIES = 200; -export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "transit"] as const; +export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const; export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number]; export type EngineCallResult = diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index 5b0b00df..eb459335 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = { chartTypeSheetTitle: "星盘类型", chartTypeMenu: "全部盘型", chartTypeSheetClose: "关闭星盘类型", + bhavaNote: "宫制 Sripati:用出生时刻的上升和天顶把四个象限各分三份,行星按宫界重新归宫。星座度数和本命盘相同。", transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。", precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。", unauthenticated: "请先登录后再看星盘。", diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index 23ac94c1..06dc13db 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -167,7 +167,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ return enginePayload(result); }; const transitDate = input.asOf.slice(0, 10); - const [varga, chara, western, transit] = await Promise.all([ + const [varga, chara, western, bhava, transit] = await Promise.all([ layers.has("varga") ? callEngine(input.postEngine, "/api/varga_full", { ...followUp, @@ -184,6 +184,16 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ layers.has("western") ? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western")) : Promise.resolve(null), + // Sripati needs the birth moment for the sidereal MC (BUG-1077); followUp + // carries it. `house_system: "P"` in the birth payload is the western + // wheel's and must not reach this call. + layers.has("bhava") + ? callEngine(input.postEngine, "/api/bhava_chalit", { + ...followUp, + house_system: "sripati", + mode: "compare", + }).then(layerPayload("bhava")) + : Promise.resolve(null), layers.has("transit") ? callEngine( input.postEngine, @@ -206,6 +216,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ varga, chara, western, + bhava, transit, profile, asOf: input.asOf, diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 56c3b0bd..8982cf87 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -28,6 +28,7 @@ import { transitsFromEngine } from "./ephemeris-view.ts"; import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts"; import { chartViewOkSchema, + type ChartViewBhavaOk, type ChartViewMoonChart, type ChartViewOk, type ChartViewTransitOk, @@ -57,6 +58,7 @@ export type ChartViewEngineBundle = { varga: Record | null; chara: Record | null; western: Record | null; + bhava?: Record | null; transit?: Record | null; profile: ChartViewProfileInput; asOf: string; @@ -481,6 +483,92 @@ function mapTransit( }; } +function unavailableBhava(reason: string): ChartViewUnavailableLayer { + return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable }; +} + +function signAt(lon: number): { sign: (typeof SIGN_ORDER)[number]; degree: number } { + const normalized = ((lon % 360) + 360) % 360; + return { sign: SIGN_ORDER[Math.floor(normalized / 30) % 12]!, degree: normalized % 30 }; +} + +/** + * Sripati Bhava Chalit from `/api/bhava_chalit` (mode `compare`). Refuses to + * draw anything the engine did not compute as Sripati from a real MC: a + * fallback, a missing boundary table, or the old `asc + 270` placeholder MC + * (BUG-1077) all come back unavailable instead of an equal-house chart. + */ +function mapBhava(raw: Record | null | undefined, chart: Record): ChartViewBhavaOk | ChartViewUnavailableLayer { + if (!raw) return unavailableBhava("not_requested"); + const result = record(raw.result); + if (!result) return unavailableBhava("payload_incomplete"); + if (text(raw.selected_house_system) !== "sripati" || text(raw.fallback_reason)) return unavailableBhava("house_system_fallback"); + const asc = finite(result.ascendant_lon); + const mc = finite(result.mc_lon); + if (asc === null || mc === null) return unavailableBhava("mc_missing"); + const placeholder = (asc + 270) % 360; + if (Math.abs(mc - placeholder) < 1e-6) return unavailableBhava("mc_placeholder"); + const natalAsc = text(record(chart.ascendant)?.sign); + if (!natalAsc || !isSignName(natalAsc)) return unavailableBhava("natal_ascendant_missing"); + const rashi = asPlanetMap(result.rashi_chart); + const bhavaHouses = record(result.bhava_chart) ?? {}; + const rows: ChartViewBhavaOk["rows"] = []; + for (const name of PLANET_ORDER) { + const planet = rashi[name]; + const sign = text(planet?.sign); + const rashiHouse = finite(planet?.house); + const bhavaHouse = finite(bhavaHouses[name]); + if (!planet || !sign || !isSignName(sign) || rashiHouse === null || bhavaHouse === null) continue; + rows.push({ + name, + label: planetZh(name), + sign, + signLabel: signZh(sign), + degreeInSign: Math.min(29.9999, Math.max(0, finite(planet.degree_in_sign) ?? 0)), + rashiHouse: Math.trunc(rashiHouse), + bhavaHouse: Math.trunc(bhavaHouse), + shifted: Math.trunc(bhavaHouse) !== Math.trunc(rashiHouse), + }); + } + const houses = Array.isArray(record(result.boundaries)?.houses) ? record(result.boundaries)!.houses as unknown[] : []; + const cusps: ChartViewBhavaOk["cusps"] = houses.flatMap((entry) => { + const house = record(entry); + const number = finite(house?.house); + const cusp = finite(house?.cusp_lon); + const sandhi = finite(house?.sandhi_start_lon); + if (!house || number === null || cusp === null || sandhi === null) return []; + const at = signAt(cusp); + const start = signAt(sandhi); + return [{ + house: Math.trunc(number), + sign: at.sign, + signLabel: signZh(at.sign), + degreeInSign: at.degree, + sandhiSign: start.sign, + sandhiSignLabel: signZh(start.sign), + sandhiDegreeInSign: start.degree, + }]; + }); + if (rows.length === 0 || cusps.length !== 12) return unavailableBhava("payload_incomplete"); + // Draw each body in its Bhava house: the North Indian block places a body by + // sign, so hand it the sign that occupies that house from the natal lagna. + const ascIndex = SIGN_ORDER.indexOf(natalAsc); + const block: ReportChartBlock = { + version: 1, + id: "D1", + title: "Bhava", + layout: "north", + ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) }, + planets: rows.map((row) => ({ + name: row.name as ReportChartBlock["planets"][number]["name"], + sign: SIGN_ORDER[(ascIndex + row.bhavaHouse - 1) % 12]!, + degree: Math.min(29.999, row.degreeInSign), + retrograde: Boolean(asPlanetMap(chart.planets)[row.name]?.retrograde), + })), + }; + return { status: "ok", houseSystem: "Sripati", chart: toNorthIndianChart(block), rows, cusps }; +} + function vimshottariTrack(chart: Record, asOf: string) { const dasha = record(chart.dasha) ?? {}; const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : []; @@ -699,7 +787,7 @@ function mapWestern(raw: Record | null, ayanamsaDisplay: string } export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { - const { chart, varga, chara, western, transit, profile, asOf } = bundle; + const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); @@ -753,6 +841,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { dualTrackNote: DASHA_DUAL_TRACK_NOTE, }, western: mapWestern(western, ayanamsaDisplay), + bhava: mapBhava(bhava, chart), transit: mapTransit(transit, chart, profile, asOf), }; return chartViewOkSchema.parse(view); diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index 14108bfc..8b440940 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -214,6 +214,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise< ...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}), dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) }, ...(prior.western.status === "ok" ? { western: prior.western } : {}), + ...(prior.bhava.status === "ok" ? { bhava: prior.bhava } : {}), ...(prior.transit.status === "ok" ? { transit: prior.transit } : {}), } }; } diff --git a/frontend/tests/chart-bhava.test.tsx b/frontend/tests/chart-bhava.test.tsx new file mode 100644 index 00000000..3e64e652 --- /dev/null +++ b/frontend/tests/chart-bhava.test.tsx @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; + +Object.assign(globalThis, { React }); + +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +// Real `/api/bhava_chalit` response to the body chart-view-load sends (see its sourceCommand). +const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8")); + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +async function assemble(bhavaPayload: Record = bhavaGolden.bhava_chalit) { + const calls: Array<{ path: string; body: Record }> = []; + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + layers: ["bhava"], + postEngine: async (path, body) => { + calls.push({ path, body }); + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/bhava_chalit") return { status: "ok", payload: bhavaPayload }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return { view: chartViewOkSchema.parse(result.body), calls }; +} + +function render(view: ChartViewOk, props: Record = {}) { + return renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "BHAVA", onSelectVarga: () => {}, ...props })); +} + +test("the bhava layer asks for Sripati with the birth moment the MC needs (BUG-1077)", async () => { + const { calls } = await assemble(); + const call = calls.find((item) => item.path === "/api/bhava_chalit"); + assert.ok(call); + assert.equal(call.body.house_system, "sripati", "the western wheel's house_system P must not leak in"); + assert.equal(call.body.mode, "compare"); + for (const key of ["year", "month", "day", "hour", "minute", "lat", "lon", "tz", "ayanamsa"]) assert.ok(call.body[key] !== undefined, key); + assert.equal(call.body.mc_lon, undefined, "the page never sends a guessed MC"); + assert.ok(call.body.planets && call.body.ascendant); +}); + +test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses", async () => { + const { view } = await assemble(); + assert.equal(view.bhava.status, "ok"); + if (view.bhava.status !== "ok") throw new Error("expected bhava ok"); + const engine = bhavaGolden.bhava_chalit.result; + assert.equal(view.bhava.rows.length, 9); + for (const row of view.bhava.rows) { + assert.equal(row.bhavaHouse, engine.bhava_chart[row.name]); + assert.equal(row.rashiHouse, engine.rashi_chart[row.name].house); + } + const shifted = view.bhava.rows.filter((row) => row.shifted).map((row) => row.name).sort(); + assert.deepEqual(shifted, engine.shifts.map((item: { planet: string }) => item.planet).sort()); + assert.ok(shifted.length > 0, "this chart has at least one body that changes house"); + assert.equal(view.bhava.cusps.length, 12); + // Not the old equal-house chart: the boundary spans differ. + const starts = view.bhava.cusps.map((cusp) => cusp.sandhiDegreeInSign); + assert.ok(new Set(starts.map((value) => value.toFixed(2))).size > 1); + for (const row of view.bhava.rows) { + const house = view.bhava.chart.houses[row.bhavaHouse - 1]!; + assert.ok(house.occupantGlyphs?.some((glyph) => glyph.key === row.name.toLowerCase()), `${row.name} drawn in Bhava house ${row.bhavaHouse}`); + } + + const markup = render(view); + assert.ok(markup.includes(CHART_VIEW_COPY.bhavaNote)); + assert.match(markup, /Bhava · Sripati<\/caption>/); + assert.match(markup, /宫头与宫界<\/caption>/); + assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length); + assert.equal((markup.match(/是<\/td>/g) ?? []).length, shifted.length); +}); + +test("an equal-house placeholder MC or a house-system fallback is never drawn", async () => { + const response = bhavaGolden.bhava_chalit; + const asc = response.result.ascendant_lon as number; + const placeholder = structuredClone(response); + placeholder.result.mc_lon = (asc + 270) % 360; + const fallback = structuredClone(response); + fallback.selected_house_system = "sripati"; + fallback.fallback_reason = "placidus requires swisseph plus valid birth date"; + for (const payload of [placeholder, fallback]) { + const { view } = await assemble(payload); + assert.equal(view.bhava.status, "unavailable"); + const markup = render(view, { failure: "unavailable" }); + assert.doesNotMatch(markup, /宫头与宫界|data-skeleton/); + } +}); + +test("the bhava tab waits with the chart skeleton and shows the layer failure copy", async () => { + const { view } = await assemble(); + const bare = { ...view, bhava: { status: "unavailable" as const, reason: "not_requested", copy: CHART_VIEW_COPY.unavailable } }; + assert.match(render(bare, { pending: true }), /data-skeleton="true"/); + assert.ok(render(bare, { pending: false, failure: "engine_busy" }).includes(CHART_VIEW_COPY.busy)); +}); diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index f9a7b496..8cbcadb0 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -12,6 +12,7 @@ import { peekChartPage, pinChartSnapshotIdentity, prefetchSecondaryPage, resetSe import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8")); const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" }; async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) { @@ -20,7 +21,7 @@ async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) { return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, _body, cacheScope) => { if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart }; if (failure) return failure; - const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : golden.western; + const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : path === "/api/bhava_chalit" ? bhavaGolden.bhava_chalit : golden.western; return { status: "ok", payload }; } }); } @@ -84,9 +85,9 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]); })); -// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 transit -// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层新增(T4) -for (const layer of ["varga", "chara", "western", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => { +// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 bhava 与 transit +// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层(T4)与 Bhava 层(T5)新增 +for (const layer of ["varga", "chara", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => { const initial = await packet(); const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 }); globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial); diff --git a/frontend/tests/fixtures/chart-view-bhava-golden.json b/frontend/tests/fixtures/chart-view-bhava-golden.json new file mode 100644 index 00000000..287c911b --- /dev/null +++ b/frontend/tests/fixtures/chart-view-bhava-golden.json @@ -0,0 +1,294 @@ +{ + "sourceCommand": "python3: jyotish_api_server.JyotishAPIHandler._compute_bhava_chalit with the chart-view-load body (birth payload + chart-view-golden chart.planets/ascendant/houses, house_system=sripati, mode=compare) after BUG-1077 (MC from bhava_chalit_mc.sidereal_mc_from_birth). Fictional 1990-06-15 12:00 Beijing, Raman.", + "bhava_chalit": { + "success": true, + "endpoint": "bhava_chalit", + "mode": "compare", + "requested_house_system": "sripati", + "selected_house_system": "sripati", + "available_house_systems": [ + "equal", + "koch", + "placidus", + "porphyry", + "sripati", + "whole_sign" + ], + "fallback_reason": "", + "calculation_note": "Bhava Chalit uses Sripati (Vedic standard, Porphyry variant).", + "result": { + "house_system": "sripati", + "house_system_desc": "Sripati (Vedic standard, Porphyry variant)", + "ascendant_lon": 149.3994, + "mc_lon": 58.152, + "rashi_chart": { + "Sun": { + "sign": "Gemini", + "house": 11, + "degree_in_sign": 1.5338 + }, + "Moon": { + "sign": "Aquarius", + "house": 7, + "degree_in_sign": 18.6462 + }, + "Mars": { + "sign": "Pisces", + "house": 8, + "degree_in_sign": 18.5242 + }, + "Mercury": { + "sign": "Taurus", + "house": 10, + "degree_in_sign": 12.8439 + }, + "Jupiter": { + "sign": "Gemini", + "house": 11, + "degree_in_sign": 23.5398 + }, + "Venus": { + "sign": "Aries", + "house": 9, + "degree_in_sign": 26.1082 + }, + "Saturn": { + "sign": "Capricorn", + "house": 6, + "degree_in_sign": 1.774 + }, + "Rahu": { + "sign": "Capricorn", + "house": 6, + "degree_in_sign": 17.4381 + }, + "Ketu": { + "sign": "Cancer", + "house": 12, + "degree_in_sign": 17.4381 + } + }, + "bhava_chart": { + "Sun": 10, + "Moon": 7, + "Mars": 8, + "Mercury": 9, + "Jupiter": 11, + "Venus": 9, + "Saturn": 5, + "Rahu": 6, + "Ketu": 12 + }, + "shifts": [ + { + "planet": "Sun", + "sign": "Gemini", + "degree_in_sign": 1.5338, + "rashi_house": 11, + "bhava_house": 10, + "shift_direction": "backward", + "shift_magnitude": 1, + "note": "Sun 从第11宫偏移到第10宫 (backward)" + }, + { + "planet": "Mercury", + "sign": "Taurus", + "degree_in_sign": 12.8439, + "rashi_house": 10, + "bhava_house": 9, + "shift_direction": "backward", + "shift_magnitude": 1, + "note": "Mercury 从第10宫偏移到第9宫 (backward)" + }, + { + "planet": "Saturn", + "sign": "Capricorn", + "degree_in_sign": 1.774, + "rashi_house": 6, + "bhava_house": 5, + "shift_direction": "backward", + "shift_magnitude": 1, + "note": "Saturn 从第6宫偏移到第5宫 (backward)" + } + ], + "shifted_count": 3, + "boundaries": { + "house_system": "sripati", + "house_system_desc": "Sripati (Vedic standard, Porphyry variant)", + "ascendant_lon": 149.3994, + "mc_lon": 58.152, + "cusps": [ + 149.3994, + 178.9836, + 208.5678, + 238.152, + 268.5678, + 298.9836, + 329.3994, + 358.9836, + 28.5678, + 58.152, + 88.5678, + 118.9836 + ], + "sandhis": [ + 134.1915, + 164.1915, + 193.7757, + 223.3599, + 253.3599, + 283.7757, + 314.1915, + 344.1915, + 13.7757, + 43.3599, + 73.3599, + 103.7757 + ], + "houses": [ + { + "house": 1, + "cusp_lon": 149.3994, + "cusp_sign": "Leo", + "cusp_sign_cn": "狮子座", + "cusp_degree_in_sign": 29.3994, + "sandhi_start_lon": 134.1915, + "sandhi_end_lon": 164.1915, + "span_degrees": 30.0 + }, + { + "house": 2, + "cusp_lon": 178.9836, + "cusp_sign": "Virgo", + "cusp_sign_cn": "处女座", + "cusp_degree_in_sign": 28.9836, + "sandhi_start_lon": 164.1915, + "sandhi_end_lon": 193.7757, + "span_degrees": 29.5842 + }, + { + "house": 3, + "cusp_lon": 208.5678, + "cusp_sign": "Libra", + "cusp_sign_cn": "天秤座", + "cusp_degree_in_sign": 28.5678, + "sandhi_start_lon": 193.7757, + "sandhi_end_lon": 223.3599, + "span_degrees": 29.5842 + }, + { + "house": 4, + "cusp_lon": 238.152, + "cusp_sign": "Scorpio", + "cusp_sign_cn": "天蝎座", + "cusp_degree_in_sign": 28.152, + "sandhi_start_lon": 223.3599, + "sandhi_end_lon": 253.3599, + "span_degrees": 30.0 + }, + { + "house": 5, + "cusp_lon": 268.5678, + "cusp_sign": "Sagittarius", + "cusp_sign_cn": "射手座", + "cusp_degree_in_sign": 28.5678, + "sandhi_start_lon": 253.3599, + "sandhi_end_lon": 283.7757, + "span_degrees": 30.4158 + }, + { + "house": 6, + "cusp_lon": 298.9836, + "cusp_sign": "Capricorn", + "cusp_sign_cn": "摩羯座", + "cusp_degree_in_sign": 28.9836, + "sandhi_start_lon": 283.7757, + "sandhi_end_lon": 314.1915, + "span_degrees": 30.4158 + }, + { + "house": 7, + "cusp_lon": 329.3994, + "cusp_sign": "Aquarius", + "cusp_sign_cn": "水瓶座", + "cusp_degree_in_sign": 29.3994, + "sandhi_start_lon": 314.1915, + "sandhi_end_lon": 344.1915, + "span_degrees": 30.0 + }, + { + "house": 8, + "cusp_lon": 358.9836, + "cusp_sign": "Pisces", + "cusp_sign_cn": "双鱼座", + "cusp_degree_in_sign": 28.9836, + "sandhi_start_lon": 344.1915, + "sandhi_end_lon": 13.7757, + "span_degrees": 29.5842 + }, + { + "house": 9, + "cusp_lon": 28.5678, + "cusp_sign": "Aries", + "cusp_sign_cn": "白羊座", + "cusp_degree_in_sign": 28.5678, + "sandhi_start_lon": 13.7757, + "sandhi_end_lon": 43.3599, + "span_degrees": 29.5842 + }, + { + "house": 10, + "cusp_lon": 58.152, + "cusp_sign": "Taurus", + "cusp_sign_cn": "金牛座", + "cusp_degree_in_sign": 28.152, + "sandhi_start_lon": 43.3599, + "sandhi_end_lon": 73.3599, + "span_degrees": 30.0 + }, + { + "house": 11, + "cusp_lon": 88.5678, + "cusp_sign": "Gemini", + "cusp_sign_cn": "双子座", + "cusp_degree_in_sign": 28.5678, + "sandhi_start_lon": 73.3599, + "sandhi_end_lon": 103.7757, + "span_degrees": 30.4158 + }, + { + "house": 12, + "cusp_lon": 118.9836, + "cusp_sign": "Cancer", + "cusp_sign_cn": "巨蟹座", + "cusp_degree_in_sign": 28.9836, + "sandhi_start_lon": 103.7757, + "sandhi_end_lon": 134.1915, + "span_degrees": 30.4158 + } + ] + }, + "summary": { + "total_planets": 9, + "shifted_count": 3, + "shifted_names": [ + "Sun", + "Mercury", + "Saturn" + ] + }, + "requested_house_system": "sripati", + "selected_house_system": "sripati", + "available_house_systems": [ + "equal", + "koch", + "placidus", + "porphyry", + "sripati", + "whole_sign" + ], + "fallback_reason": "", + "calculation_note": "Bhava Chalit uses Sripati (Vedic standard, Porphyry variant)." + } + } +} \ No newline at end of file diff --git a/scripts/bhava_chalit_mc.py b/scripts/bhava_chalit_mc.py new file mode 100644 index 00000000..1d7148d7 --- /dev/null +++ b/scripts/bhava_chalit_mc.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Sidereal MC for `/api/bhava_chalit` (BUG-1077). + +Sripati and Porphyry trisect the quadrants between the ascendant and the MC. +The endpoint used to default a missing `mc_lon` to `asc + 270`, which makes +every quadrant exactly 90 degrees: the chart silently became equal houses and +no planet could ever change house. This module is the only place the endpoint +gets an MC from: + +1. an explicit `mc_lon` in the body wins (existing callers and tests); +2. otherwise, with the birth moment and place in the body, the MC comes from + Swiss Ephemeris `houses_ex` in the sidereal frame of the request's ayanamsa, + the same call the full-reading producer uses + (`jyotish_engine._build_natal_foundation_modules`); +3. otherwise a house system that needs the MC is refused, never guessed. + +House systems that do not read the MC keep the old placeholder, which only +appears as the echoed `mc_lon`. +""" + +from __future__ import annotations + +from typing import Any + +from ayanamsa_utils import normalize_ayanamsa_name, temporary_ayanamsa + +MC_HOUSE_SYSTEMS = frozenset({'sripati', 'porphyry'}) +_BIRTH_KEYS = ('year', 'month', 'day', 'hour', 'lat', 'lon') + + +def _has_birth_moment(body: dict) -> bool: + return all(body.get(key) is not None for key in _BIRTH_KEYS) + + +def sidereal_mc_from_birth(handler: Any, body: dict) -> float: + """MC longitude in the request's sidereal frame, from the birth moment.""" + import swisseph as swe + + year = handler._get_int(body, 'year', 1990, 1800, 2400) + month = handler._get_int(body, 'month', 6, 1, 12) + day = handler._get_int(body, 'day', 15, 1, 31) + hour = handler._get_float(body, 'hour', 12, 0, 23) + minute = handler._get_float(body, 'minute', 0, 0, 59) + second = handler._get_birth_second(body) + lat = handler._get_float(body, 'lat', 0, -90, 90) + lon = handler._get_float(body, 'lon', 0, -180, 180) + tz = handler._parse_timezone(body, lat, lon, year, month, day, hour, minute, second) + jd = swe.julday(year, month, day, handler._birth_hour_decimal(hour, minute, second) - tz) + name = normalize_ayanamsa_name(body.get('ayanamsa') or body.get('ayanamsa_name')) + with temporary_ayanamsa(name, swe): + _, ascmc = swe.houses_ex(jd, lat, lon, b'R', swe.FLG_SWIEPH | swe.FLG_SIDEREAL) + return float(ascmc[1]) % 360 + + +def resolve_bhava_mc_lon(handler: Any, body: dict, house_system: str, asc_lon: float, bad_request: type[Exception]) -> float: + if body.get('mc_lon') is not None: + return handler._normalize_degree(body, 'mc_lon', 0) + if _has_birth_moment(body): + return sidereal_mc_from_birth(handler, body) + if house_system in MC_HOUSE_SYSTEMS: + raise bad_request(f'{house_system} needs mc_lon or the birth date, time and place (year, month, day, hour, lat, lon)') + return (asc_lon + 270) % 360 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 1b00df60..b988f118 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -9596,11 +9596,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM asc_lon = self._normalize_degree(body, 'asc_lon', 0) else: asc_lon = float(asc_lon) % 360 - mc_lon = self._normalize_degree(body, 'mc_lon', (asc_lon + 270) % 360) house_system = body.get('house_system', 'sripati') if not isinstance(house_system, str): raise BadRequest('house_system must be a string') requested_house_system = house_system.lower().strip() + mc_lon = _load_local_module('bhava_chalit_mc').resolve_bhava_mc_lon(self, body, requested_house_system, asc_lon, BadRequest) mode = body.get('mode', 'compare') if not isinstance(mode, str): raise BadRequest('mode must be a string') diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index e4dc65da..3cb0fca4 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -3281,6 +3281,58 @@ def test_bhava_chalit_endpoint_exposes_user_selected_house_systems() -> None: assert result['result']['calculation_note'] +def _golden_natal_body() -> dict: + """Real engine natal packet the chart page uses (fictional 1990-06-15 12:00 Beijing, Raman, mean node).""" + golden = json.loads((Path(__file__).resolve().parents[1] / 'frontend/tests/fixtures/chart-view-golden.json').read_text(encoding='utf-8')) + chart = golden['chart'] + return { + 'planets': {name: {'lon': row['lon']} for name, row in chart['planets'].items()}, + 'ascendant': {'lon': chart['ascendant']['lon']}, + 'year': 1990, 'month': 6, 'day': 15, 'hour': 12, 'minute': 0, 'second': 0, + 'lat': 39.9042, 'lon': 116.4074, 'tz': 8, + 'ayanamsa': 'raman', + 'house_system': 'sripati', + 'mode': 'compare', + } + + +def test_bhava_chalit_sripati_without_mc_or_birth_moment_is_rejected_not_equal_houses() -> None: + """BUG-1077: a missing MC used to default to asc+270, silently turning Sripati into equal houses.""" + body = _golden_natal_body() + for key in ('year', 'month', 'day', 'hour', 'minute', 'second', 'lat', 'lon', 'tz'): + body.pop(key) + with pytest.raises(BadRequest, match='mc_lon'): + _handler()._compute_bhava_chalit(body) + + +def test_bhava_chalit_sripati_derives_the_sidereal_mc_from_the_birth_moment() -> None: + import swisseph as swe + from ayanamsa_utils import temporary_ayanamsa + + body = _golden_natal_body() + result = _handler()._compute_bhava_chalit(body)['result'] + spans = [house['span_degrees'] for house in result['boundaries']['houses']] + assert len(spans) == 12 + # Before BUG-1077 every span was exactly 30.0 (equal houses). + assert any(abs(span - 30.0) > 0.1 for span in spans), spans + + jd = swe.julday(1990, 6, 15, 12 - 8) + with temporary_ayanamsa('raman', swe): + _, ascmc = swe.houses_ex(jd, 39.9042, 116.4074, b'R', swe.FLG_SWIEPH | swe.FLG_SIDEREAL) + assert abs(result['mc_lon'] - ascmc[1] % 360) < 1e-3 + # Same frame as the natal chart: the ascendant from that call matches the engine's. + assert abs(ascmc[0] % 360 - body['ascendant']['lon']) < 0.01 + + +def test_bhava_chalit_non_mc_house_systems_keep_working_without_birth_moment() -> None: + body = _golden_natal_body() + for key in ('year', 'month', 'day', 'hour', 'minute', 'second', 'lat', 'lon', 'tz'): + body.pop(key) + result = _handler()._compute_bhava_chalit({**body, 'house_system': 'equal'}) + assert result['success'] is True + assert [house['span_degrees'] for house in result['result']['boundaries']['houses']] == [30.0] * 12 + + def test_sudarshana_endpoint_returns_three_reference_points() -> None: handler = _handler() result = handler._compute_sudarshana({