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
+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 } : {}),
} };
}