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:
co-authored by
Claude Opus 5.5
parent
3f6bcb081a
commit
c35e0195e1
@@ -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} />;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = {
|
||||
chartTypeSheetTitle: "星盘类型",
|
||||
chartTypeMenu: "全部盘型",
|
||||
chartTypeSheetClose: "关闭星盘类型",
|
||||
bhavaNote: "宫制 Sripati:用出生时刻的上升和天顶把四个象限各分三份,行星按宫界重新归宫。星座度数和本命盘相同。",
|
||||
transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。",
|
||||
precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。",
|
||||
unauthenticated: "请先登录后再看星盘。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user