fix(report,chart): blank columns — drop Kranti, split Ashtakavarga house SAV, ascendant nakshatra, engine varga dignity, no duplicate charts (BUG-1199..1203)
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m42s

- Report p33 table: no Kranti column (no producer; materials.kranti stays blocked).
- Report p53 table: the "SAV" column was the with-Lagna total (386). Split in the
  reader into SAV / Lagna BAV / SAV + Lagna with a totals row; ashtakavarga.py is
  untouched (frozen rectification identity, ERR-115).
- /api/chart ascendant carries nakshatra / pada / lord (_nakshatra_fields shared with
  the nine bodies); chart cache schema 4 -> 5.
- /api/varga_full bodies carry dignity_level from varga_dignity_level, the function
  the Vimsopaka path now shares (A/B identical); the chart page shows the D1 status
  wording and no longer derives a three-level dignity itself.
- Report page draws each chart once: the trailing block skips charts the body already
  fenced (same rule as the export); the birth-time caveat moves under the body's
  chart heading.

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-10-03 09:11:15 +08:00
co-authored by Claude Opus 5.5
parent 0e856f9cb6
commit ae18952e51
28 changed files with 1011 additions and 346 deletions
+2 -2
View File
@@ -2575,7 +2575,7 @@ def _vedastro_runtime_fingerprint() -> dict:
def _build_api_chart_cache_payload(body: dict) -> dict:
return {
'cache_schema_version': 4,
'cache_schema_version': 5,
'birth': {
'year': body.get('year'),
'month': body.get('month'),
@@ -7820,7 +7820,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
result = {}
for varga in selected:
key = f'D{varga.division}_{varga.varga_name}'
result[key] = calc._calculate_single_varga(varga, planet_lons, asc_lon)
result[key] = _load_local_module('varga_dignity').annotate_varga_dignity(calc._calculate_single_varga(varga, planet_lons, asc_lon))
except KeyError as e:
raise BadRequest('unsupported standard division; use custom mode for arbitrary D-N') from e
except ValueError as e:
+33 -12
View File
@@ -701,6 +701,20 @@ def _get_dignity_level(planet, sign, deg_in_sign=None, planets_data=None):
return 'NEUTRAL'
def varga_dignity_level(planet, varga_planets):
"""Dignity of a body inside a divisional chart (Vimsopaka and the chart page alike).
The temporary half of the compound relationship is read from that varga's
own positions, as the Vimsopaka path always has. Returns None when the body
has no usable sign.
"""
pinfo = varga_planets.get(planet) if isinstance(varga_planets, dict) else None
if not isinstance(pinfo, dict) or pinfo.get('sign') not in SIGN_LORDS:
return None
deg_in_sign = pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30)
return _get_dignity_level(planet, pinfo['sign'], deg_in_sign, _build_dignity_context(varga_planets))
def _get_planet_status_label(planet, sign, deg_in_sign=None, planets_data=None):
"""Return the user-facing D1 dignity label for chart output."""
return DIGNITY_LABELS.get(_get_dignity_level(planet, sign, deg_in_sign, planets_data), '中性')
@@ -12228,6 +12242,15 @@ def _load_strict_evidence_collector():
# ============================================================================
# 公共星盘计算(供 chart/shadbala/ashtakavarga 共用,v3.4提取)
# ============================================================================
def _nakshatra_fields(sidereal_lon):
"""Nakshatra, pada and lord of a sidereal longitude — planets and the ascendant alike."""
nak_span = 360.0 / 27
lon = sidereal_lon % 360
pada = int((lon % nak_span) / (nak_span / 4)) + 1
name, lord, _ = NAKSHATRA_LIST[int(lon / nak_span) % 27]
return {"nakshatra": name, "nakshatra_pada": pada, "nakshatra_lord": lord}
def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='mean', second=0, ayanamsa_name=None):
"""计算星盘核心数据,返回 (result_dict, asc_idx, jd, ayanamsa)。node_mode: mean|true。"""
if not HAS_SWE:
@@ -12264,7 +12287,8 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
"degree_in_sign": round(deg_in_sign, 4),
"degree_in_sign_raw": deg_in_sign,
"lon": round(asc_deg, 4),
"lord": SIGN_LORDS[asc_sign]}
"lord": SIGN_LORDS[asc_sign],
**_nakshatra_fields(asc_deg)}
for i in range(12):
c = (asc_lon[i] - ayanamsa) % 360
@@ -12273,7 +12297,6 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
"cusp_sign_cn": SIGNS_CN[SIGNS[si]], "cusp_degree": round(c, 4),
"lord": SIGN_LORDS[SIGNS[si]]}
nak_span = 360.0 / 27
planets_swe = {**BASE_PLANETS_SWE, 'Rahu': node_pid}
calc_flags = getattr(swe, 'FLG_SWIEPH', 2)
for pname, pid in planets_swe.items():
@@ -12283,26 +12306,22 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
si = int(lon_p / 30); d_in_s = lon_p - si * 30; sign = SIGNS[si]
retro = spd < 0
house = ((si - asc_idx) % 12) + 1
ni = int(lon_p / nak_span); pada = int((lon_p % nak_span) / (nak_span / 4)) + 1
nak_n, nak_l, _ = NAKSHATRA_LIST[ni % 27]
result["planets"][pname] = {
"sign": sign, "sign_cn": SIGNS_CN[sign], "degree": round(lon_p, 4),
"degree_raw": lon_p,
"degree_in_sign": round(d_in_s, 4), "degree_in_sign_raw": d_in_s,
"house": house, "status": "",
"retrograde": retro, "speed": round(spd, 6),
"nakshatra": nak_n, "nakshatra_pada": pada, "nakshatra_lord": nak_l}
**_nakshatra_fields(lon_p)}
if pname == 'Rahu':
klon = (lon_p + 180) % 360; ksi = int(klon / 30); kd = klon - ksi * 30
kni = int(klon / nak_span); kp = int((klon % nak_span) / (nak_span / 4)) + 1
kn, kl, _ = NAKSHATRA_LIST[kni % 27]
result["planets"]["Ketu"] = {
"sign": SIGNS[ksi], "sign_cn": SIGNS_CN[SIGNS[ksi]],
"degree": round(klon, 4), "degree_raw": klon,
"degree_in_sign": round(kd, 4), "degree_in_sign_raw": kd,
"house": ((ksi - asc_idx) % 12) + 1, "status": "",
"retrograde": True, "speed": round(spd, 6),
"nakshatra": kn, "nakshatra_pada": kp, "nakshatra_lord": kl}
**_nakshatra_fields(klon)}
except Exception as e:
result["planets"][pname] = {"error": str(e)}
@@ -15689,10 +15708,12 @@ def cmd_full_reading(args):
# 不使用 _dignity 字典(只有 Exalted/Debilitated/Own Sign)
pinfo = vdata.get(pn, {})
if isinstance(pinfo, dict) and 'sign' in pinfo:
dignity_context = planets if div == 1 else _build_dignity_context(vdata)
dl_key = _get_dignity_level(pn, pinfo['sign'],
pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30),
dignity_context)
if div == 1:
dl_key = _get_dignity_level(pn, pinfo['sign'],
pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30),
planets)
else:
dl_key = varga_dignity_level(pn, vdata)
planet_vargas_input[pn][vt] = dignity_map.get(dl_key, DignityLevel.NEUTRAL)
elif div == 1:
# D1 直接用本命盘数据
+3 -1
View File
@@ -35,6 +35,8 @@ _PHRASES: dict[str, str] = {
"特殊上升与派生点补充": "Special Lagnas and Points (supplement)",
"敏感点与特殊落点": "Sensitive Points and Special Placements",
"本命与分盘北印度图盘": "Natal and Divisional Charts (North Indian)",
"分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。":
"Divisional charts are sensitive to birth time. The raw calculations are here for checking and do not by themselves make a conclusion more certain.",
" 数值位置表": " Position Table",
"Shadbala / Bhava Bala 摘要": "Shadbala / Bhava Bala Summary",
"Avastha 行星状态": "Avastha Planetary States",
@@ -84,7 +86,7 @@ _PHRASES: dict[str, str] = {
"Sign lord, star lord, sub lord and sub-sub lord: the rulers of the planet's sign, nakshatra and KP subdivisions. Strength ratio: Shadbala against the required strength; 1 or above meets it.",
"| 对象 | 落座 | 落座度数 | 黄经 | 整宫宫位 |": "| Body | Sign | Degree in sign | Longitude | Whole-sign house |",
"| House | Sign | Lord | Score | 关键因素 |": "| House | Sign | Lord | Score | Key factors |",
"| House | Sign | SAV | BAV/承接 | Full with Lagna |": "| House | Sign | SAV | BAV / carry | Full with Lagna |",
"| 合计 | - |": "| Total | - |",
"| 序号 | 主运 | 起始 | 结束 | 年数 |": "| No. | Mahadasha | Start | End | Years |",
"| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |": "| Vimshottari AD | Mahadasha | Antardasha | Start | End |",
"| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |": "| Vimshottari PD | Mahadasha | Antardasha | Pratyantardasha | Start | End |",
+35 -12
View File
@@ -1008,7 +1008,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
]
ready_natal_specs = [spec for spec in natal_specs if spec[2]]
if ready_natal_specs:
lines.extend(['### p6-p17 本命与分盘北印度图盘', ''])
lines.extend(['### p6-p17 本命与分盘北印度图盘', '', '分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。', ''])
for page, label, _ready in ready_natal_specs:
lines.extend([f'##### p{page} · {label}', ''])
# The original PL9 charts are visual, but their sign placements are
@@ -1140,12 +1140,13 @@ def render_pl9_parity_markdown(packet: dict) -> str:
)
lines.append('')
lines.extend([
'### p33 Declination / Kranti / Speed',
'### p33 Declination / Speed',
'',
'| Planet | Degree | Declination | Kranti | Speed |',
'|--------|--------|-------------|--------|-------|',
'| Planet | Degree | Declination | Speed |',
'|--------|--------|-------------|-------|',
])
kranti_planets = _dict(_dict(strengths.get('kranti_local')).get('planets'))
# Kranti has no local producer (materials.kranti stays blocked), so
# the reader shows no Kranti column rather than a column of dashes.
speed_planets = _dict(_dict(strengths.get('speed_display_local')).get('planets'))
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
row = _dict(shadbala_planets.get(planet))
@@ -1153,11 +1154,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
continue
kala = _dict(row.get('kala_bala'))
planet_row = _dict((packet.get('core_chart') or {}).get('planets', {}).get(planet)) if isinstance((packet.get('core_chart') or {}).get('planets'), dict) else {}
kranti_degrees = _dict(kranti_planets.get(planet)).get('kranti_degrees')
speed_dms = _dict(speed_planets.get(planet)).get('speed_dms') or _num(planet_row.get('speed'), 6)
lines.append(
f"| {_cell(_token(planet))} | {_cell(_degree(planet_row.get('degree_in_sign') or planet_row.get('longitude')))} | "
f"{_cell(_degree(kala.get('declination')))} | {_cell(_degree(kranti_degrees))} | {_cell(speed_dms)} |"
f"{_cell(_degree(kala.get('declination')))} | {_cell(speed_dms)} |"
)
lines.append('')
avasthas = _dict(advanced.get('avasthas'))
@@ -1329,15 +1329,38 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### p53 Ashtakavarga 完整宫位分数',
'',
'| House | Sign | SAV | BAV/承接 | Full with Lagna |',
'|-------|------|-----|----------|-----------------|',
'| House | Sign | SAV | Lagna BAV | SAV + Lagna |',
'|-------|------|-----|-----------|-------------|',
])
# `house_scores_full[*].sav_score` is the with-Lagna total (386
# scale), never plain SAV (BUG-1200). Split it from the engine's
# per-sign SAV and the Lagna BAV; ashtakavarga.py stays untouched
# because it belongs to the frozen rectification identity.
sign_list = list(globals().get('SIGNS', []))
lagna_bindus = _dict(_dict(ashtakavarga.get('bav')).get('Lagna')).get('bindus')
splits = []
for row in full_rows:
sign = row.get('sign')
sav_value = sav_scores.get(sign)
lagna_value = None
if isinstance(lagna_bindus, list) and len(lagna_bindus) == 12 and sign in sign_list:
lagna_value = lagna_bindus[sign_list.index(sign)]
consistent = (
isinstance(sav_value, int) and isinstance(lagna_value, int)
and sav_value + lagna_value == row.get('sav_score')
)
splits.append((sav_value, lagna_value) if consistent else None)
split = all(splits)
for row, parts in zip(full_rows, splits):
sav_value, lagna_value = parts if split else (None, None)
lines.append(
f"| {_cell(row.get('house'))} | {_cell(_token(row.get('sign')))} | "
f"{_cell(row.get('sav') or row.get('sav_score') or row.get('score'))} | "
f"{_cell(row.get('bav') or row.get('bav_score') or row.get('bindus'))} | "
f"{_cell(row.get('full_with_lagna') or row.get('full_with_lagna_house_score'))} |"
f"{_cell(sav_value)} | {_cell(lagna_value)} | {_cell(row.get('sav_score'))} |"
)
if split:
lines.append(
f"| 合计 | - | {sum(parts[0] for parts in splits)} | "
f"{sum(parts[1] for parts in splits)} | {sum(row['sav_score'] for row in full_rows)} |"
)
lines.append('')
bav = _dict(ashtakavarga.get('bav'))
+24
View File
@@ -0,0 +1,24 @@
"""Engine dignity on each body of a `/api/varga_full` divisional chart (BUG-1202).
The chart page's divisional tables show the same ten-level dignity as the D1
"状态" column. The level is `jyotish_engine.varga_dignity_level`, the function
the Vimsopaka path uses, so the page never carries a second rule set.
"""
from __future__ import annotations
from jyotish_engine import varga_dignity_level
def annotate_varga_dignity(varga: dict) -> dict:
"""Add `dignity_level` to every body of one `_calculate_single_varga` result."""
planets = varga.get("planets") if isinstance(varga, dict) else None
if not isinstance(planets, dict):
return varga
for name, row in planets.items():
if not isinstance(row, dict):
continue
level = varga_dignity_level(name, planets)
if level:
row["dignity_level"] = level
return varga