宫头与宫界<\/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({
|