fix(chart): Sripati Bhava from the real sidereal MC; Bhava chart and tables (T5, BUG-1077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:38:24 +08:00
co-authored by Claude Opus 5.5
parent 3f6bcb081a
commit c35e0195e1
16 changed files with 743 additions and 9 deletions
+3
View File
@@ -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;
@@ -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";
}
@@ -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 (
<>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>Bhava · {bhava.houseSystem}</caption>
<thead>
<tr>
<th scope="col">行星</th>
<th scope="col">黄道经度</th>
<th scope="col">星座宫</th>
<th scope="col">Bhava 宫</th>
<th scope="col">换宫</th>
</tr>
</thead>
<tbody>
{bhava.rows.map((row) => {
const key = planetGlyphKey(row.name);
return (
<tr key={row.name} className={row.shifted ? "is-shifted" : undefined}>
<th scope="row">
<PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} />
</th>
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
<td className="chart-page-planet-mid">{row.rashiHouse}宫</td>
<td className="chart-page-planet-mid">{row.bhavaHouse}宫</td>
<td className="chart-page-planet-mid">{row.shifted ? "是" : "否"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>宫头与宫界</caption>
<thead>
<tr>
<th scope="col">宫</th>
<th scope="col">宫头</th>
<th scope="col">起点(sandhi)</th>
</tr>
</thead>
<tbody>
{bhava.cusps.map((cusp) => (
<tr key={cusp.house}>
<th scope="row">{cusp.house}宫</th>
<td className="chart-page-planet-num">{cusp.signLabel} {formatDegreeMinutes(cusp.degreeInSign)}</td>
<td className="chart-page-planet-num">{cusp.sandhiSignLabel} {formatDegreeMinutes(cusp.sandhiDegreeInSign)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/** 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 }: {
</VedicLayout>
);
}
if (vargaId === "BHAVA") {
const bhava = view.bhava;
if (bhava.status !== "ok") return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingChart} />;
return (
<VedicLayout view={view} chart={bhava.chart} ariaLabel="Bhava 北印度盘 Sripati 不等宫">
<div className="chart-page-varga-table-block">
<p className="chart-page-note chart-page-table-note">{CHART_VIEW_COPY.bhavaNote}</p>
<BhavaTables bhava={bhava} />
</div>
</VedicLayout>
);
}
if (vargaId === "TRANSIT") {
const transit = view.transit;
if (transit.status !== "ok") return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingChart} />;
+1
View File
@@ -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 } : {}),
};
}
+32
View File
@@ -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<typeof chartViewResponseSchema>;
export type ChartViewUnavailableLayer = z.infer<typeof chartViewUnavailableLayerSchema>;
export type ChartViewWesternOk = z.infer<typeof chartViewWesternOkSchema>;
export type ChartViewTransitOk = z.infer<typeof chartViewTransitOkSchema>;
export type ChartViewBhavaOk = z.infer<typeof chartViewBhavaOkSchema>;
export type ChartViewMoonChart = z.infer<typeof chartViewMoonChartSchema>;
export type ChartViewVarga = z.infer<typeof chartViewVargaSchema>;
export type ChartViewVargaRow = z.infer<typeof chartViewVargaRowSchema>;
+1 -1
View File
@@ -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 =
+1
View File
@@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = {
chartTypeSheetTitle: "星盘类型",
chartTypeMenu: "全部盘型",
chartTypeSheetClose: "关闭星盘类型",
bhavaNote: "宫制 Sripati:用出生时刻的上升和天顶把四个象限各分三份,行星按宫界重新归宫。星座度数和本命盘相同。",
transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。",
precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。",
unauthenticated: "请先登录后再看星盘。",
+12 -1
View File
@@ -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,
+90 -1
View File
@@ -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<string, unknown> | null;
chara: Record<string, unknown> | null;
western: Record<string, unknown> | null;
bhava?: Record<string, unknown> | null;
transit?: Record<string, unknown> | 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<string, unknown> | null | undefined, chart: Record<string, unknown>): 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<string, unknown>, asOf: string) {
const dasha = record(chart.dasha) ?? {};
const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : [];
@@ -699,7 +787,7 @@ function mapWestern(raw: Record<string, unknown> | 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);
+1
View File
@@ -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 } : {}),
} };
}
+115
View File
@@ -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<string, unknown> = bhavaGolden.bhava_chalit) {
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
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<string, unknown> = {}) {
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, /<caption>Bhava · Sripati<\/caption>/);
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/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));
});
+5 -4
View File
@@ -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);
+294
View File
@@ -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)."
}
}
}
+62
View File
@@ -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
+1 -1
View File
@@ -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')
+52
View File
@@ -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({