diff --git a/frontend/src/lib/consultation-evidence-card.ts b/frontend/src/lib/consultation-evidence-card.ts index 7772c842..907ab3c5 100644 --- a/frontend/src/lib/consultation-evidence-card.ts +++ b/frontend/src/lib/consultation-evidence-card.ts @@ -59,6 +59,11 @@ export const EVIDENCE_CARD_LABELS = { * conclusion, never counted as affliction or as support against it. */ traditionalYogas: "传统格局,本站未验证,只作参考", + /** + * What the two Ashtakavarga house values are (astrologer ruling 乙8, + * BUG-1209): the reduced value is the main one, the raw value stands beside it. + */ + savColumns: "sav_sodhita:三方净化 + 单宫主净化后的 SAV(主值);sav_score:原始 SAV;level 按原始值分档", } as const; /** KP stays off every card: exact cusps are still blocked (decision table). */ @@ -288,6 +293,8 @@ export type EvidenceCardDomainSection = { ashtakavarga?: JsonRecord; shadbala?: Json; sav_by_house?: JsonRecord; + /** BUG-1209: what `sav_sodhita` and `sav_score` are (EVIDENCE_CARD_LABELS.savColumns). */ + sav_columns?: string; slow_transits?: Json; double_transit?: Json; punarphoo?: Json; @@ -474,6 +481,8 @@ function rolePlanetRow(base: EvidenceCard["base"], name: string): JsonRecord | u return klass ? { ...row, functional: klass } : row; } const ARUDHA_KEYS = ["sign", "lord"] as const; +/** BUG-1209: reduced SAV first, raw SAV beside it (labels: EVIDENCE_CARD_LABELS.savColumns). */ +const SAV_HOUSE_KEYS = ["sign", "sav_sodhita", "sav_score", "level"] as const; /** The engine writes the Arudha Lagna as pada A1 (its own name: "Arudha Lagna (AL)"). */ const ARUDHA_ENGINE_KEY: Readonly> = { AL: "A1" }; const D9_ROW_KEYS = ["d9_sign", "d9_dignity", "vargottama"] as const; @@ -793,13 +802,14 @@ function domainSection( const houseScores = record(table.house_scores); const byHouse: JsonRecord = {}; for (const number of rule.houses) { - const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]); + const row = pickKeys(houseScores[`house_${number}`], SAV_HOUSE_KEYS); if (nonEmpty(row)) byHouse[String(number)] = row; } const strongest = copy(table.strongest_signs); const weakest = copy(table.weakest_signs); if (nonEmpty(byHouse) || strongest !== undefined) { section.ashtakavarga = { + sav_columns: EVIDENCE_CARD_LABELS.savColumns, sav_by_house: byHouse, ...(strongest !== undefined ? { strongest_signs: strongest } : {}), ...(weakest !== undefined ? { weakest_signs: weakest } : {}), @@ -817,11 +827,12 @@ function domainSection( const houseScores = record(record(localLayers.ashtakavarga).house_scores); const byHouse: JsonRecord = {}; for (const number of rule.savHouses) { - const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]); + const row = pickKeys(houseScores[`house_${number}`], SAV_HOUSE_KEYS); if (nonEmpty(row)) byHouse[String(number)] = row; else gaps.push(`${domain}.sav.${number}`); } section.sav_by_house = byHouse; + section.sav_columns = EVIDENCE_CARD_LABELS.savColumns; } if (rule.slowTransits !== "none") { const current = record(slow.current); diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 8e8b772f..4de0bdaa 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -1351,16 +1351,7 @@ function readShadbalaRanking(workflow: JsonRecord): ReportShadbalaRankFact[] { })); } -function readSavFacts( - workflow: JsonRecord, - ascendantSignIndex: number | null, -): Readonly<{ scores: ReportSavScoreFact[]; total: number | null }> { - const snapshot = readEvidenceSnapshot(workflow); - const modules = readChartModules(workflow); - const sav = record(record(modules.ashtakavarga)?.sav); - const strength = record(snapshot?.strength); - const rawScores = record(sav?.scores) ?? record(strength?.sav_scores); - const rawTotal = finiteNumber(sav?.total ?? strength?.sav_total); +function savByHouse(rawScores: JsonRecord | null, ascendantSignIndex: number | null): Map { const scores = new Map(); for (const [key, value] of Object.entries(rawScores ?? {})) { const score = finiteNumber(value); @@ -1377,12 +1368,61 @@ function readSavFacts( if (house === null || scores.has(house)) continue; scores.set(house, score); } + return scores; +} + +function boundedTotal(value: number | null): number | null { + return value !== null && value >= 0 && value <= 1000 ? value : null; +} + +/** + * BUG-1209 (ruling 乙8): the main SAV is the engine's Sodhita SAV (Trikona then + * Ekadhipatya reduction, `ashtakavarga.sodhita`); the raw SAV stands beside it. + * A workflow without the reduction keeps the raw values alone. + */ +function readSavFacts( + workflow: JsonRecord, + ascendantSignIndex: number | null, +): Readonly<{ scores: ReportSavScoreFact[]; total: number | null; rawTotal: number | null }> { + const snapshot = readEvidenceSnapshot(workflow); + const modules = readChartModules(workflow); + const ashtakavarga = record(modules.ashtakavarga); + const sav = record(ashtakavarga?.sav); + const strength = record(snapshot?.strength); + const raw = savByHouse(record(sav?.scores) ?? record(strength?.sav_scores), ascendantSignIndex); + const rawTotal = boundedTotal(finiteNumber(sav?.total ?? strength?.sav_total)); + const sodhita = record(ashtakavarga?.sodhita); + const sodhitaSav = sodhita?.status === "executed" ? record(sodhita.sodhita_sav) : null; + const reduced = savByHouse(record(sodhitaSav?.scores), ascendantSignIndex); + if (reduced.size === 12 && raw.size === 12) { + return { + scores: [...reduced.entries()] + .map(([house, score]) => ({ house, score, rawScore: raw.get(house)! })) + .sort((a, b) => a.house - b.house), + total: boundedTotal(finiteNumber(sodhitaSav?.total)), + rawTotal, + }; + } return { - scores: [...scores.entries()].map(([house, score]) => ({ house, score })).sort((a, b) => a.house - b.house), - total: rawTotal !== null && rawTotal >= 0 && rawTotal <= 1000 ? rawTotal : null, + scores: [...raw.entries()].map(([house, score]) => ({ house, score })).sort((a, b) => a.house - b.house), + total: rawTotal, + rawTotal: null, }; } +function savScoreText(row: ReportSavScoreFact): string { + return row.rawScore === undefined + ? `第 ${row.house} 宫 ${row.score} 分` + : `第 ${row.house} 宫 净化后 ${row.score} 分(原始 ${row.rawScore} 分)`; +} + +function savTotalText(total: number | null, rawTotal: number | null | undefined, lead: string): string { + if (total === null) return ""; + return rawTotal === null || rawTotal === undefined + ? `${lead}全盘合计 ${total} 分` + : `${lead}全盘净化后合计 ${total} 分(原始 ${rawTotal} 分)`; +} + function readBirthTimeSensitivity(workflow: JsonRecord): ReportBirthTimeSensitivityFact | undefined { const packet = record(workflow.birth_time_sensitivity); const window = record(packet?.window); @@ -1707,10 +1747,11 @@ function buildInterpretiveThemeLines( const savHouses = THEME_SAV_HOUSES[theme] ?? []; const savLine = facts.savScores .filter((row) => savHouses.includes(row.house)) - .map((row) => `第 ${row.house} 宫 ${row.score} 分`) + .map(savScoreText) .join(","); if (savLine) { - const total = facts.savTotal === null ? "" : `(全盘合计 ${facts.savTotal} 分)`; + const totalText = savTotalText(facts.savTotal, facts.savRawTotal, ""); + const total = totalText ? `(${totalText})` : ""; lines.push(`本主题相关宫位的八分力总分:${savLine}${total}。`); } if (facts.currentDasha) { @@ -1905,7 +1946,7 @@ export function buildReportEvidenceBundleV2( const result = readSavFacts(workflow, baseAscendantSignIndex); if (result.scores.length > 0 || result.total !== null) return result; } - return { scores: [] as ReportSavScoreFact[], total: null as number | null }; + return { scores: [] as ReportSavScoreFact[], total: null as number | null, rawTotal: null as number | null }; })(); const currentDasha = (() => { for (const { workflow } of packets) { @@ -1926,6 +1967,7 @@ export function buildReportEvidenceBundleV2( shadbalaRanking: collectFirst(readShadbalaRanking), savScores: ashtakavargaRef ? savFacts.scores : [], savTotal: ashtakavargaRef ? savFacts.total : null, + ...(ashtakavargaRef && savFacts.rawTotal !== null ? { savRawTotal: savFacts.rawTotal } : {}), currentDasha: vimshottariRef ? currentDasha : null, convergenceDomains: collectFirst(readConvergenceDomains), planetaryFriendship, @@ -2057,9 +2099,9 @@ export function buildReportEvidenceBundleV2( const houses = THEME_SAV_HOUSES[plan.theme] ?? []; const scored = interpretiveFacts.savScores .filter((row) => houses.includes(row.house)) - .map((row) => `第 ${row.house} 宫 ${row.score} 分`) + .map(savScoreText) .join(","); - const total = interpretiveFacts.savTotal === null ? "" : `,全盘合计 ${interpretiveFacts.savTotal} 分`; + const total = savTotalText(interpretiveFacts.savTotal, interpretiveFacts.savRawTotal, ","); return `八分力:${scored || "本主题宫位未取得分值"}${total}`; } if (technique === "Functional Benefic/Malefic" && interpretiveFacts.functionalRoles.length > 0) { @@ -3405,6 +3447,7 @@ export function filterReportEvidenceBundleForSection( ? source.interpretiveFacts.savScores.filter((row) => savHouses.includes(row.house)) : source.interpretiveFacts.savScores, savTotal: source.interpretiveFacts.savTotal, + ...(source.interpretiveFacts.savRawTotal !== undefined ? { savRawTotal: source.interpretiveFacts.savRawTotal } : {}), currentDasha: source.interpretiveFacts.currentDasha, convergenceDomains: source.interpretiveFacts.convergenceDomains, planetaryFriendship: source.interpretiveFacts.planetaryFriendship, diff --git a/frontend/src/lib/report-evidence-bundle-v2.ts b/frontend/src/lib/report-evidence-bundle-v2.ts index 8de6f6ab..81375516 100644 --- a/frontend/src/lib/report-evidence-bundle-v2.ts +++ b/frontend/src/lib/report-evidence-bundle-v2.ts @@ -154,7 +154,12 @@ export type ReportShadbalaRankFact = Readonly<{ rank: number; rupa: number | null; }>; -export type ReportSavScoreFact = Readonly<{ house: number; score: number }>; +/** + * `score` is the Sodhita SAV (Trikona then Ekadhipatya reduction) when the + * engine supplied it, with the raw SAV in `rawScore`; without the reduction + * `score` is the raw SAV and `rawScore` is absent (BUG-1209, ruling 乙8). + */ +export type ReportSavScoreFact = Readonly<{ house: number; score: number; rawScore?: number }>; export type ReportCurrentDashaFact = Readonly<{ mahadasha: string; antardasha: string | null; @@ -203,7 +208,10 @@ export type ReportInterpretiveFacts = Readonly<{ functionalRoles: readonly ReportFunctionalRoleFact[]; shadbalaRanking: readonly ReportShadbalaRankFact[]; savScores: readonly ReportSavScoreFact[]; + /** Sodhita total when `savScores` carry `rawScore`, else the raw total. */ savTotal: number | null; + /** Raw SAV total beside a Sodhita `savTotal` (BUG-1209). */ + savRawTotal?: number | null; currentDasha: ReportCurrentDashaFact | null; convergenceDomains: readonly string[]; planetaryFriendship: readonly ReportPlanetaryFriendshipFact[]; @@ -377,8 +385,10 @@ const interpretiveFactsSchema = z.object({ savScores: z.array(z.object({ house: houseNumberSchema, score: z.number().finite().min(0).max(100), + rawScore: z.number().finite().min(0).max(100).optional(), }).strict()).max(12), savTotal: z.number().finite().min(0).max(1000).nullable(), + savRawTotal: z.number().finite().min(0).max(1000).nullable().optional(), currentDasha: z.object({ mahadasha: celestialSchema, antardasha: celestialSchema.nullable(), diff --git a/frontend/src/lib/report-fact-tables.ts b/frontend/src/lib/report-fact-tables.ts index 61653d65..e1e45de6 100644 --- a/frontend/src/lib/report-fact-tables.ts +++ b/frontend/src/lib/report-fact-tables.ts @@ -5,7 +5,7 @@ import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable const SOURCES = [ ["vimshottari", "Vimshottari 主运", "timing_and_predictive_systems.dasha", "dasha", ["主运", "起", "止", "年数", "当前"]], - ["ashtakavarga", "SAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga", ["星座", "SAV 点数"]], + ["ashtakavarga", "SAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga", ["星座", "净化后 SAV", "原始 SAV"]], ["shadbala", "Shadbala 六分量", "strengths_and_scores.shadbala", "shadbala", ["行星", "位置力", "方向力", "时间力", "动态力", "自然力", "相位力", "合计"]], ["functional_roles", "功能吉凶", "strengths_and_scores.functional_benefic_malefic", "functional_benefic_malefic", ["类别", "行星"]], ["avasthas", "Avastha 行星状态", "advanced_systems.avasthas", "avasthas", ["行星", "Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"]], @@ -23,7 +23,7 @@ const EMPTY = "本次没有可展示的数据,不补算、不补写。"; */ const EN_SOURCES: Readonly> = { vimshottari: ["Vimshottari mahadashas", ["Mahadasha", "Start", "End", "Years", "Current"]], - ashtakavarga: ["SAV Ashtakavarga", ["Sign", "SAV points"]], + ashtakavarga: ["SAV Ashtakavarga", ["Sign", "Sodhita SAV", "Raw SAV"]], shadbala: ["Shadbala", ["Planet", "Sthana", "Dig", "Kala", "Chesta", "Naisargika", "Drik", "Total"]], functional_roles: ["Functional benefics and malefics", ["Group", "Planet"]], avasthas: ["Avasthas (planetary states)", ["Planet", "Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"]], @@ -125,12 +125,26 @@ export function assembleReportFactTables(packet: unknown, language: ReportLangua children.unshift(ad); }); } else if (id === "ashtakavarga") { + // BUG-1209 (ruling 乙8): Sodhita SAV (Trikona then Ekadhipatya) first, + // the raw SAV beside it; reduced BAV rows before the raw BAV. + const sodhita = record(raw.sodhita); + const reduced = sodhita.status === "executed" ? record(at(sodhita, "sodhita_sav.scores")) : {}; for (const [name, score] of entries(at(raw, "sav.scores"))) { const label = L.sign(name); - if (label) add(table, `${sourcePath}.sav.scores.${name}`, [label, decimal(score)]); + if (label) add(table, `${sourcePath}.sav.scores.${name}`, [label, decimal(reduced[name]), decimal(score)]); } - const bav = en ? subtable("bav", "BAV Ashtakavarga", `${sourcePath}.bav`, ["Planet", ...SIGN_ORDER]) - : subtable("bav", "BAV 八分力", `${sourcePath}.bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); + if (sodhita.status === "executed") { + const sodhitaBav = en ? subtable("sodhita_bav", "Sodhita BAV (after Trikona and Ekadhipatya reduction)", `${sourcePath}.sodhita.sodhita_bav`, ["Planet", ...SIGN_ORDER]) + : subtable("sodhita_bav", "净化后 BAV(三方净化 + 单宫主净化)", `${sourcePath}.sodhita.sodhita_bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); + for (const [name, value] of entries(sodhita.sodhita_bav)) { + if (!planet(name)) continue; + const row = list(value); + add(sodhitaBav, `${sourcePath}.sodhita.sodhita_bav.${name}`, [L.planet(name), ...SIGN_ORDER.map((_, index) => decimal(row[index]))]); + } + children.push(sodhitaBav); + } + const bav = en ? subtable("bav", "Raw BAV Ashtakavarga", `${sourcePath}.bav`, ["Planet", ...SIGN_ORDER]) + : subtable("bav", "原始 BAV 八分力", `${sourcePath}.bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); for (const [name, value] of entries(raw.bav)) { if (!planet(name) || isBlocked(value)) continue; const bindus = list(record(value).bindus); diff --git a/frontend/tests/fixtures/report-density-fictional-engine.json b/frontend/tests/fixtures/report-density-fictional-engine.json index fb0840b1..477b366c 100644 --- a/frontend/tests/fixtures/report-density-fictional-engine.json +++ b/frontend/tests/fixtures/report-density-fictional-engine.json @@ -17,6 +17,11 @@ "house_system": "whole_sign", "today": "2026-09-22", "target_year": 2026 + }, + "refreshed": { + "leaf": "worksheets.strengths_and_scores.ashtakavarga", + "reason": "BUG-1209 standard Sodhita chain (TASK-astrologer-rulings-batch1-20261003 T7)", + "generator": "scripts/research/refresh_density_fixture_ashtakavarga.py" } }, "worksheets": { @@ -19319,62 +19324,74 @@ "house_1": { "sign": "Pisces", "sav_score": 28, - "level": "吉利" + "level": "吉利", + "sav_sodhita": 6 }, "house_2": { "sign": "Aries", "sav_score": 25, - "level": "中等" + "level": "中等", + "sav_sodhita": 1 }, "house_3": { "sign": "Taurus", "sav_score": 27, - "level": "中等" + "level": "中等", + "sav_sodhita": 2 }, "house_4": { "sign": "Gemini", "sav_score": 23, - "level": "挑战" + "level": "挑战", + "sav_sodhita": 4 }, "house_5": { "sign": "Cancer", "sav_score": 25, - "level": "中等" + "level": "中等", + "sav_sodhita": 8 }, "house_6": { "sign": "Leo", "sav_score": 35, - "level": "极吉" + "level": "极吉", + "sav_sodhita": 11 }, "house_7": { "sign": "Virgo", "sav_score": 29, - "level": "吉利" + "level": "吉利", + "sav_sodhita": 6 }, "house_8": { "sign": "Libra", "sav_score": 29, - "level": "吉利" + "level": "吉利", + "sav_sodhita": 11 }, "house_9": { "sign": "Scorpio", "sav_score": 26, - "level": "中等" + "level": "中等", + "sav_sodhita": 9 }, "house_10": { "sign": "Sagittarius", "sav_score": 33, - "level": "极吉" + "level": "极吉", + "sav_sodhita": 9 }, "house_11": { "sign": "Capricorn", "sav_score": 26, - "level": "中等" + "level": "中等", + "sav_sodhita": 2 }, "house_12": { "sign": "Aquarius", "sav_score": 31, - "level": "极吉" + "level": "极吉", + "sav_sodhita": 13 } }, "house_scores_full": { @@ -19428,197 +19445,584 @@ } }, "sodhita": { - "method": "Sodhita Ashtakavarga (BPHS标准)", - "version": "1.0", - "sodhita_bav": { + "status": "executed", + "method": "Trikona Shodhana -> Ekadhipatya Shodhana (BPHS ch. 67), per BAV row; Sodhita SAV = sum of reduced BAV", + "method_zh": "三方净化 → 单宫主净化(BPHS 第 67 章,逐行 BAV);净化后 SAV = 七星净化后 BAV 之和", + "source": "internal_reference_omitted", + "occupied_signs": [ + "Aries", + "Libra", + "Scorpio", + "Sagittarius", + "Aquarius" + ], + "raw_bav": { "Sun": [ - 2, 3, - 1, - 1, - 4, - 2, - 2, - 1, - 3, - 1, - 3, - 1 - ], - "Moon": [ - 3, - 3, - 2, - 4, - 3, - 2, - 3, - 1, - 3, - 3, - 3, - 3 - ], - "Mars": [ - 2, - 2, - 1, - 0, - 2, - 2, - 2, - 0, - 2, - 2, - 2, - 3 - ], - "Mercury": [ - 2, - 2, - 2, - 2, - 3, - 3, - 2, - 4, - 3, - 3, - 3, - 4 - ], - "Jupiter": [ 5, 2, 3, - 3, + 6, 4, + 5, 3, - 2, + 6, 3, - 3, - 3, - 2, + 5, 3 ], + "Moon": [ + 4, + 4, + 5, + 6, + 4, + 4, + 4, + 2, + 4, + 3, + 5, + 4 + ], + "Mars": [ + 4, + 4, + 1, + 1, + 3, + 4, + 4, + 2, + 4, + 3, + 5, + 4 + ], + "Mercury": [ + 4, + 5, + 2, + 3, + 5, + 4, + 5, + 7, + 5, + 4, + 5, + 5 + ], + "Jupiter": [ + 5, + 3, + 5, + 4, + 7, + 6, + 3, + 4, + 5, + 4, + 4, + 6 + ], "Venus": [ + 3, + 4, + 4, + 5, + 5, 2, - 3, - 3, - 2, - 4, - 2, - 3, - 4, - 3, - 4, - 4, + 5, + 6, + 5, + 6, + 5, 2 ], "Saturn": [ - 1, 2, - 1, - 1, + 2, + 4, + 3, + 5, + 5, + 3, + 2, 4, 3, 2, + 4 + ] + }, + "trikona_bav": { + "Sun": [ + 0, + 2, + 0, + 0, + 3, 1, + 3, + 0, + 3, + 0, + 3, + 0 + ], + "Moon": [ + 0, + 1, + 1, + 4, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 2 + ], + "Mars": [ + 1, + 1, + 0, + 0, + 0, + 1, + 3, + 1, + 1, + 0, + 4, + 3 + ], + "Mercury": [ + 0, + 1, + 0, + 0, + 1, + 0, + 3, + 4, + 1, + 0, + 3, + 2 + ], + "Jupiter": [ + 0, + 0, + 2, + 0, + 2, + 3, + 0, + 0, + 0, + 1, + 1, + 2 + ], + "Venus": [ + 0, + 2, + 0, + 3, + 2, + 0, + 1, + 4, + 2, + 4, + 1, + 0 + ], + "Saturn": [ + 0, + 0, + 2, + 1, + 3, + 3, + 1, + 0, + 2, + 1, + 0, + 2 + ] + }, + "sodhita_bav": { + "Sun": [ + 0, + 0, + 0, + 0, + 3, + 1, + 3, + 0, + 3, + 0, + 3, + 0 + ], + "Moon": [ + 0, + 1, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2 + ], + "Mars": [ + 1, + 0, + 0, + 0, + 0, + 1, + 3, + 1, + 1, + 0, + 4, + 1 + ], + "Mercury": [ + 0, + 0, + 0, + 0, + 1, + 0, + 3, + 4, + 1, + 0, + 3, + 1 + ], + "Jupiter": [ + 0, + 0, + 2, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 1, + 2 + ], + "Venus": [ + 0, + 1, + 0, + 3, + 2, + 0, + 1, + 4, 2, 1, 1, - 3 + 0 + ], + "Saturn": [ + 0, + 0, + 2, + 1, + 3, + 2, + 1, + 0, + 2, + 1, + 0, + 0 ] }, + "ekadhipatya_audit": [ + { + "planet": "Sun", + "pair": [ + 1, + 6 + ], + "before": [ + 2, + 3 + ], + "after": [ + 0, + 3 + ] + }, + { + "planet": "Moon", + "pair": [ + 2, + 5 + ], + "before": [ + 1, + 1 + ], + "after": [ + 0, + 0 + ] + }, + { + "planet": "Mars", + "pair": [ + 1, + 6 + ], + "before": [ + 1, + 3 + ], + "after": [ + 0, + 3 + ] + }, + { + "planet": "Mars", + "pair": [ + 8, + 11 + ], + "before": [ + 1, + 3 + ], + "after": [ + 1, + 1 + ] + }, + { + "planet": "Mercury", + "pair": [ + 1, + 6 + ], + "before": [ + 1, + 3 + ], + "after": [ + 0, + 3 + ] + }, + { + "planet": "Mercury", + "pair": [ + 8, + 11 + ], + "before": [ + 1, + 2 + ], + "after": [ + 1, + 1 + ] + }, + { + "planet": "Jupiter", + "pair": [ + 2, + 5 + ], + "before": [ + 2, + 3 + ], + "after": [ + 2, + 2 + ] + }, + { + "planet": "Jupiter", + "pair": [ + 9, + 10 + ], + "before": [ + 1, + 1 + ], + "after": [ + 0, + 1 + ] + }, + { + "planet": "Venus", + "pair": [ + 1, + 6 + ], + "before": [ + 2, + 1 + ], + "after": [ + 1, + 1 + ] + }, + { + "planet": "Venus", + "pair": [ + 9, + 10 + ], + "before": [ + 4, + 1 + ], + "after": [ + 1, + 1 + ] + }, + { + "planet": "Saturn", + "pair": [ + 2, + 5 + ], + "before": [ + 2, + 3 + ], + "after": [ + 2, + 2 + ] + }, + { + "planet": "Saturn", + "pair": [ + 8, + 11 + ], + "before": [ + 2, + 2 + ], + "after": [ + 2, + 0 + ] + } + ], "sodhita_sav": { "scores": { - "Aries": 17, - "Taurus": 17, - "Gemini": 13, - "Cancer": 13, - "Leo": 24, - "Virgo": 17, - "Libra": 16, - "Scorpio": 14, - "Sagittarius": 19, - "Capricorn": 17, - "Aquarius": 18, - "Pisces": 19 + "Aries": 1, + "Taurus": 2, + "Gemini": 4, + "Cancer": 8, + "Leo": 11, + "Virgo": 6, + "Libra": 11, + "Scorpio": 9, + "Sagittarius": 9, + "Capricorn": 2, + "Aquarius": 13, + "Pisces": 6 }, "assessment": [ { "sign": "Aries", - "score": 17, - "level": "挑战" + "score": 1 }, { "sign": "Taurus", - "score": 17, - "level": "挑战" + "score": 2 }, { "sign": "Gemini", - "score": 13, - "level": "挑战" + "score": 4 }, { "sign": "Cancer", - "score": 13, - "level": "挑战" + "score": 8 }, { "sign": "Leo", - "score": 24, - "level": "吉利" + "score": 11 }, { "sign": "Virgo", - "score": 17, - "level": "挑战" + "score": 6 }, { "sign": "Libra", - "score": 16, - "level": "挑战" + "score": 11 }, { "sign": "Scorpio", - "score": 14, - "level": "挑战" + "score": 9 }, { "sign": "Sagittarius", - "score": 19, - "level": "中等" + "score": 9 }, { "sign": "Capricorn", - "score": 17, - "level": "挑战" + "score": 2 }, { "sign": "Aquarius", - "score": 18, - "level": "挑战" + "score": 13 }, { "sign": "Pisces", - "score": 19, - "level": "中等" + "score": 6 } ], - "total": 204 + "total": 82 }, - "reduction_summary": { - "Sun": 24, - "Moon": 16, - "Mars": 19, - "Mercury": 21, - "Jupiter": 20, - "Venus": 16, - "Saturn": 17 + "raw_sav": { + "scores": { + "Aries": 25, + "Taurus": 27, + "Gemini": 23, + "Cancer": 25, + "Leo": 35, + "Virgo": 29, + "Libra": 29, + "Scorpio": 26, + "Sagittarius": 33, + "Capricorn": 26, + "Aquarius": 31, + "Pisces": 28 + }, + "total": 337 }, - "validation_note": "Sodhita AV ≤ 原始BAV(逐宫位)" + "transit_scoring": "raw BAV bindus (unchanged)" }, "pav": { "method": "Prastara Ashtakavarga (PAV)", diff --git a/frontend/tests/report-fact-tables.test.ts b/frontend/tests/report-fact-tables.test.ts index 3ddecb08..351dc4cc 100644 --- a/frontend/tests/report-fact-tables.test.ts +++ b/frontend/tests/report-fact-tables.test.ts @@ -58,9 +58,21 @@ test("density golden: eight server groups contain only unchanged engine leaves", } const ashtakavarga = tables.find(t => t.id === "ashtakavarga")!; assert.equal(ashtakavarga.rows.length, 12); - for (const row of ashtakavarga.rows) assert.equal(row.cells[1], resolve(row.sourcePath).toFixed(2)); - assert.equal(ashtakavarga.subtables?.[0].rows.length, 8); - for (const row of ashtakavarga.subtables![0].rows) assert.deepEqual(row.cells.slice(1), resolve(row.sourcePath).bindus.map(fixed)); + // BUG-1209 (ruling 乙8): Sodhita SAV first, raw SAV beside it; the reduced BAV + // subtable comes before the raw BAV. Was: cells[1] = raw SAV and subtables[0] = raw BAV. + const sodhita = resolve("worksheets.strengths_and_scores.ashtakavarga.sodhita"); + assert.equal(sodhita.status, "executed"); + assert.deepEqual(ashtakavarga.columns, ["星座", "净化后 SAV", "原始 SAV"]); + for (const row of ashtakavarga.rows) { + const sign = row.sourcePath.split(".").at(-1)!; + assert.equal(row.cells[1], fixed(sodhita.sodhita_sav.scores[sign])); + assert.equal(row.cells[2], resolve(row.sourcePath).toFixed(2)); + } + assert.equal(ashtakavarga.subtables?.[0].id, "sodhita_bav"); + assert.equal(ashtakavarga.subtables?.[0].rows.length, 7); + for (const row of ashtakavarga.subtables![0].rows) assert.deepEqual(row.cells.slice(1), resolve(row.sourcePath).map(fixed)); + assert.equal(ashtakavarga.subtables?.[1].rows.length, 8); + for (const row of ashtakavarga.subtables![1].rows) assert.deepEqual(row.cells.slice(1), resolve(row.sourcePath).bindus.map(fixed)); const annual = tables.find(t => t.id === "annual")!; assert.equal(annual.rows.length, 0, "golden annual chart has no planet positions"); assert.ok(annual.subtables?.[0].rows.every(row => row.sourcePath.includes("annual_tajika_pack.sahams.data"))); diff --git a/frontend/tests/report-interpretive-facts.test.ts b/frontend/tests/report-interpretive-facts.test.ts index 60cc7a34..5569ccd0 100644 --- a/frontend/tests/report-interpretive-facts.test.ts +++ b/frontend/tests/report-interpretive-facts.test.ts @@ -124,8 +124,16 @@ type FixtureOptions = Readonly<{ structuredVargas?: boolean; timingReady?: boolean; birthTimeSensitivity?: boolean; + /** BUG-1209: real engine Ashtakavarga (raw SAV + Sodhita chain) from the density golden. */ + realAshtakavarga?: boolean; }>; +const REAL_ASHTAKAVARGA = ( + JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")) as { + worksheets: { strengths_and_scores: { ashtakavarga: JsonRecord } }; + } +).worksheets.strengths_and_scores.ashtakavarga; + function workflowFixture(options: FixtureOptions = {}): JsonRecord { const interpretive = options.interpretive !== false; const chart: JsonRecord = { @@ -180,6 +188,7 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord { total: 337, }, }; + if (options.realAshtakavarga) modules.ashtakavarga = structuredClone(REAL_ASHTAKAVARGA); modules.dasha_sub_periods = { current: { mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" }, @@ -733,3 +742,23 @@ test("planetary friendship is a grade table and pratyantar stays on the timing c const filtered = filterReportEvidenceBundleForSection(timing, timingSection); assert.equal(filtered.interpretiveFacts.pratyantarTimeline?.current?.lord, "Sun"); }); + +test("SAV facts: Sodhita is the main value and the raw SAV stands beside it (BUG-1209)", () => { + const facts = buildBundle({ realAshtakavarga: true }).interpretiveFacts; + const sodhita = (REAL_ASHTAKAVARGA.sodhita as JsonRecord).sodhita_sav as { scores: Record; total: number }; + const raw = (REAL_ASHTAKAVARGA.sav as JsonRecord) as { scores: Record; total: number }; + const signs = ["Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini"]; + assert.equal(facts.savScores.length, 12); + for (const row of facts.savScores) { + const sign = signs[row.house - 1]; + assert.equal(row.score, sodhita.scores[sign], `house ${row.house}`); + assert.equal(row.rawScore, raw.scores[sign], `house ${row.house}`); + assert.ok(row.score <= row.rawScore!); + } + assert.equal(facts.savTotal, sodhita.total); + assert.equal(facts.savRawTotal, raw.total); + // Without the reduction (older workflows) the raw values stand alone. + const plain = buildBundle().interpretiveFacts; + assert.ok(plain.savScores.every((row) => row.rawScore === undefined)); + assert.equal(plain.savRawTotal, undefined); +}); diff --git a/scripts/ashtakavarga.py b/scripts/ashtakavarga.py index 32ec5182..7822b3e1 100644 --- a/scripts/ashtakavarga.py +++ b/scripts/ashtakavarga.py @@ -491,101 +491,10 @@ def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict } -def calc_sodhita_av(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict: - """ - 计算 Sodhita Ashtakavarga — 净化后的八分行星力量表 - - Sodhita 算法(BPHS标准): - 从每颗行星的每个宫位BAV中,减去Saturn、Mars和Sun的贡献(若结果为负取0)。 - 这反映了"减去凶星影响后的纯净力量"。 - - Args: - bav_results: calc_ashtakavarga返回的bav结果 - planets: 行星数据 dict - asc_sign_idx: 上升星座索引 - - Returns: - { - 'sodhita_bav': {planet: [12个sodhita值]}, - 'sodhita_sav': [12个sodhita SAV值], - 'reduction_summary': {...} - } - """ - # 1. 获取PAV展开表(需要知道每个贡献源的单独贡献) - pav_data = calc_prastara_av(planets, asc_sign_idx) - pav = pav_data['pav'] - - # 2. 定义需要减去的"凶星"贡献源 - malefic_sources = ['Saturn', 'Mars', 'Sun'] - - # 3. 计算Sodhita BAV - sodhita_bav = {} - total_reduction = {} - - for planet in SEVEN_PLANETS: - if planet not in pav: - continue - - # 原始BAV(从所有8个来源) - original_bav = [0] * 12 - for source in ALL_SOURCES: - if source in pav.get(planet, {}): - for i in range(12): - original_bav[i] += pav[planet][source][i] - - # 减去凶星贡献 - reduction = [0] * 12 - sodhita = [0] * 12 - - for i in range(12): - malefic_sum = 0 - for source in malefic_sources: - if source in pav.get(planet, {}): - malefic_sum += pav[planet][source][i] - - reduction[i] = malefic_sum - sodhita[i] = max(0, original_bav[i] - malefic_sum) - - sodhita_bav[planet] = sodhita - total_reduction[planet] = sum(reduction) - - # 4. 计算Sodhita SAV - sodhita_sav = [0] * 12 - for planet in SEVEN_PLANETS: - if planet in sodhita_bav: - for i in range(12): - sodhita_sav[i] += sodhita_bav[planet][i] - - # 5. 评估 - sodhita_assessment = [] - for i in range(12): - score = sodhita_sav[i] - if score >= 25: - level = "极吉" - elif score >= 22: - level = "吉利" - elif score >= 19: - level = "中等" - else: - level = "挑战" - sodhita_assessment.append({ - 'sign': SIGNS[i], - 'score': score, - 'level': level, - }) - - return { - 'method': 'Sodhita Ashtakavarga (BPHS标准)', - 'version': '1.0', - 'sodhita_bav': sodhita_bav, - 'sodhita_sav': { - 'scores': {SIGNS[i]: sodhita_sav[i] for i in range(12)}, - 'assessment': sodhita_assessment, - 'total': sum(sodhita_sav), - }, - 'reduction_summary': total_reduction, - 'validation_note': 'Sodhita AV ≤ 原始BAV(逐宫位)', - } +# calc_sodhita_av (removed, BUG-1209): it subtracted the Sun, Mars and Saturn +# contributions from every BAV and was labelled "BPHS标准"; that is not the BPHS +# Sodhita. The standard chain (Trikona -> Ekadhipatya per BAV row) is +# scripts/ashtakavarga_shodhana.py, built on the two functions below. TRIKONA_SIGN_GROUPS = ( diff --git a/scripts/ashtakavarga_shodhana.py b/scripts/ashtakavarga_shodhana.py new file mode 100644 index 00000000..8838659a --- /dev/null +++ b/scripts/ashtakavarga_shodhana.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Standard Ashtakavarga reduction chain for display (ruling 乙8, BUG-1209). + +BAV of each of the seven planets -> Trikona Shodhana -> Ekadhipatya Shodhana -> +Sodhita BAV; Sodhita SAV is the sum of the seven reduced BAV rows. The two +reductions are `ashtakavarga.calc_trikona_shodhana` (the minimum of each trine +removed from its three signs, i.e. PVR rules 1-3) and +`ashtakavarga.calc_ekadhipatya_shodhana` (the five dual-lordship sign pairs, +PVR rules 1-4), applied to every BAV row as BPHS ch. 67 orders them. + +Occupied signs for Ekadhipatya: signs holding one of the seven grahas (Rahu, +Ketu and the Lagna are not counted). PyJHora 4.8.7 counts any non-empty chart +cell (nodes, Lagna marker) and reduces only each planet's own sign pair in its +own row; both differences are recorded as a question for the astrologer. + +Display only: transit (gochara) scoring keeps the raw BAV bindus (decision +record of TASK-astrologer-rulings-batch1-20261003). Raw values are kept beside +the reduced ones. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +try: # scripts/ on sys.path + from ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ekadhipatya_shodhana, calc_trikona_shodhana +except ImportError: # package import + from scripts.ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ekadhipatya_shodhana, calc_trikona_shodhana + +METHOD = 'Trikona Shodhana -> Ekadhipatya Shodhana (BPHS ch. 67), per BAV row; Sodhita SAV = sum of reduced BAV' +METHOD_ZH = '三方净化 → 单宫主净化(BPHS 第 67 章,逐行 BAV);净化后 SAV = 七星净化后 BAV 之和' + + +def occupied_signs(planets: Dict[str, Any]) -> set[int]: + signs = set() + for name in SEVEN_PLANETS: + sign = (planets.get(name) or {}).get('sign') if isinstance(planets.get(name), dict) else None + if sign in SIGNS: + signs.add(SIGNS.index(sign)) + return signs + + +def sodhita_chain(bav_results: Dict[str, Any], planets: Dict[str, Any]) -> Dict[str, Any]: + """Reduced BAV rows and Sodhita SAV from `calc_ashtakavarga(...)['bav']`.""" + raw: Dict[str, List[int]] = {} + for name in SEVEN_PLANETS: + bindus = (bav_results.get(name) or {}).get('bindus') + if isinstance(bindus, list) and len(bindus) == 12: + raw[name] = [int(value) for value in bindus] + if len(raw) != len(SEVEN_PLANETS): + return {'status': 'blocked', 'reason': 'bav_rows_missing', 'method': METHOD} + trikona = {name: calc_trikona_shodhana(row)['reduced_scores'] for name, row in raw.items()} + occupied = occupied_signs(planets) + ekadhipatya = calc_ekadhipatya_shodhana(trikona, occupied) + reduced = ekadhipatya['rows'] + sav = [sum(reduced[name][index] for name in SEVEN_PLANETS) for index in range(12)] + raw_sav = [sum(raw[name][index] for name in SEVEN_PLANETS) for index in range(12)] + return { + 'status': 'executed', + 'method': METHOD, + 'method_zh': METHOD_ZH, + 'source': 'scripts/ashtakavarga_shodhana.py (ashtakavarga.calc_trikona_shodhana + calc_ekadhipatya_shodhana)', + 'occupied_signs': [SIGNS[index] for index in sorted(occupied)], + 'raw_bav': raw, + 'trikona_bav': trikona, + 'sodhita_bav': reduced, + 'ekadhipatya_audit': ekadhipatya['audit'], + 'sodhita_sav': { + 'scores': {SIGNS[index]: sav[index] for index in range(12)}, + 'assessment': [{'sign': SIGNS[index], 'score': sav[index]} for index in range(12)], + 'total': sum(sav), + }, + 'raw_sav': {'scores': {SIGNS[index]: raw_sav[index] for index in range(12)}, 'total': sum(raw_sav)}, + 'transit_scoring': 'raw BAV bindus (unchanged)', + } + + +def attach_house_values(house_scores: Dict[str, Any], chain: Dict[str, Any]) -> None: + """Add `sav_sodhita` next to the raw `sav_score` of each house row.""" + if chain.get('status') != 'executed' or not isinstance(house_scores, dict): + return + scores = chain['sodhita_sav']['scores'] + for row in house_scores.values(): + if isinstance(row, dict) and row.get('sign') in scores: + row['sav_sodhita'] = scores[row['sign']] diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index a4bfd9c6..c0bf5152 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7917,7 +7917,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM ashtakavarga = _load_local_module('ashtakavarga') result = ashtakavarga.calc_ashtakavarga(planets, asc_sign_idx) pav = ashtakavarga.calc_prastara_av(planets, asc_sign_idx) - sodhita = ashtakavarga.calc_sodhita_av(result.get('bav', {}), planets, asc_sign_idx) + # BUG-1209 (乙8): standard Trikona -> Ekadhipatya chain; raw values kept. + sodhita = _load_local_module('ashtakavarga_shodhana').sodhita_chain(result.get('bav', {}), planets) + _load_local_module('ashtakavarga_shodhana').attach_house_values(result.get('house_scores'), sodhita) yoga_pinda = result.get('yoga_pinda') or ashtakavarga.calc_yoga_pinda(result.get('bav', {}), planets, asc_sign_idx) result['pav'] = pav result['sodhita'] = sodhita @@ -8085,7 +8087,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM 'available': [ {'key': 'sav_bav', 'label': 'SAV/BAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, {'key': 'prastara_av', 'label': 'Prastara AV / PAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, - {'key': 'sodhita_av', 'label': 'Sodhita AV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, + {'key': 'sodhita_av', 'label': 'Sodhita AV (Trikona + Ekadhipatya)', 'status': 'active', 'source': 'scripts/ashtakavarga_shodhana.py'}, {'key': 'yoga_pinda', 'label': 'Yoga Pinda', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, ], 'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。', diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 7c812428..0978c7df 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -7513,23 +7513,24 @@ def render_pl9_markdown(packet: dict) -> str: if sodhita_rows: out.extend([ '', - '#### Sodhita Ashtakavarga(本地减分结果)', + '#### Sodhita Ashtakavarga(三方净化 + 单宫主净化)', '', - '本表复用既有本地 Sodhita producer,按该 producer 的 Sun / Mars / Saturn 贡献扣减生成。它不等同于 PL9 第 54 页的 Trikona / Ekadhipatya reduction 前后矩阵;逐阶段规则、矩阵与外部数值 parity 未闭环,所有行保持 parameter_sensitive。', + '逐行 BAV 先做三方净化(Trikona Shodhana),再做单宫主净化(Ekadhipatya Shodhana),净化后 SAV 为七星净化后 BAV 之和;原始 SAV 并列。外部数值 parity 未闭环,所有行保持 parameter_sensitive。', '', '| 项目 | 数值 | 状态 |', '|------|------|------|', - f"| 方法 | {_md_cell(sodhita.get('method'))} | parameter_sensitive |", - f"| 总分 | {_md_cell(sodhita_sav.get('total'))} | parameter_sensitive |", + f"| 方法 | {_md_cell(sodhita.get('method_zh') or sodhita.get('method'))} | parameter_sensitive |", + f"| 净化后总分 | {_md_cell(sodhita_sav.get('total'))} | parameter_sensitive |", '', - '| 星座 | Sodhita SAV | 本地等级 | 状态 |', - '|------|-------------|----------|------|', + '| 星座 | 净化后 SAV | 原始 SAV | 状态 |', + '|------|------------|----------|------|', ]) + raw_scores = (sodhita.get('raw_sav') or {}).get('scores') or {} for row in sodhita_rows: if isinstance(row, dict): out.append( f"| {_md_cell(_humanize_reader_token(row.get('sign')))} | {_md_cell(row.get('score'))} | " - f"{_md_cell(row.get('level'))} | parameter_sensitive |" + f"{_md_cell(raw_scores.get(row.get('sign')))} | parameter_sensitive |" ) pav = ashtakavarga.get('pav') if isinstance(ashtakavarga.get('pav'), dict) else {} pav_summary = pav.get('pav_summary') if isinstance(pav.get('pav_summary'), dict) else {} @@ -16490,9 +16491,11 @@ def cmd_full_reading(args): # ── Step 11: Ashtakavarga八分法 ── asht_data = None try: - from ashtakavarga import calc_ashtakavarga, calc_prastara_av, calc_sodhita_av + from ashtakavarga import calc_ashtakavarga, calc_prastara_av + from ashtakavarga_shodhana import attach_house_values, sodhita_chain # BUG-1209 asht_data = calc_ashtakavarga(planets, asc_idx) - asht_data['sodhita'] = calc_sodhita_av(asht_data.get('bav', {}), planets, asc_idx) + asht_data['sodhita'] = sodhita_chain(asht_data.get('bav', {}), planets) + attach_house_values(asht_data.get('house_scores'), asht_data['sodhita']) asht_data['pav'] = calc_prastara_av(planets, asc_idx) report['modules']['ashtakavarga'] = asht_data except Exception as e: diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 3b673e79..882a34c2 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1310,19 +1310,34 @@ def render_pl9_parity_markdown(packet: dict) -> str: ashtakavarga = _dict(strengths.get('ashtakavarga')) sav = _dict(ashtakavarga.get('sav')) sav_scores = _dict(sav.get('scores')) + # BUG-1209 (乙8): the main value is the Sodhita SAV (Trikona then + # Ekadhipatya per BAV row); the raw value stands beside it. + sodhita = _dict(ashtakavarga.get('sodhita')) + sodhita_ok = sodhita.get('status') == 'executed' + sodhita_scores = _dict(_dict(sodhita.get('sodhita_sav')).get('scores')) if sodhita_ok else {} + sodhita_bav = _dict(sodhita.get('sodhita_bav')) if sodhita_ok else {} + english = _pl9_report_language(packet) == 'en' if sav_scores: lines.extend([ '### p51-p60 Ashtakavarga SAV / BAV / Pinda', '', f"- 方法:{_cell(ashtakavarga.get('method'))}", f"- BAV 校验:{_cell('all_valid' if ashtakavarga.get('all_bav_valid') else ashtakavarga.get('all_bav_valid'))}", + ]) + if sodhita_ok: + lines.append( + f"- Reduction: {_cell(sodhita.get('method'))}" if english + else f"- 净化:{_cell(sodhita.get('method_zh') or sodhita.get('method'))};原始值并列" + ) + lines.extend([ '', - '| Sign | SAV score |', - '|------|-----------|', + '| Sign | Sodhita SAV | Raw SAV |' if sodhita_ok else '| Sign | Raw SAV |', + '|------|-------------|---------|' if sodhita_ok else '|------|---------|', ]) for sign in globals().get('SIGNS', []): if sign in sav_scores: - lines.append(f"| {_cell(_token(sign))} | {_cell(sav_scores.get(sign))} |") + main = f"{_cell(sodhita_scores.get(sign))} | " if sodhita_ok else '' + lines.append(f"| {_cell(_token(sign))} | {main}{_cell(sav_scores.get(sign))} |") lines.append('') full_house_scores = ashtakavarga.get('house_scores_full') if isinstance(full_house_scores, dict): @@ -1335,8 +1350,8 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.extend([ '### p53 Ashtakavarga 完整宫位分数', '', - '| House | Sign | SAV | Lagna BAV | SAV + Lagna |', - '|-------|------|-----|-----------|-------------|', + '| House | Sign | Sodhita SAV | Raw SAV | Lagna BAV | Raw 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 @@ -1361,15 +1376,32 @@ def render_pl9_parity_markdown(packet: dict) -> str: sav_value, lagna_value = parts if split else (None, None) lines.append( f"| {_cell(row.get('house'))} | {_cell(_token(row.get('sign')))} | " + f"{_cell(sodhita_scores.get(row.get('sign')))} | " f"{_cell(sav_value)} | {_cell(lagna_value)} | {_cell(row.get('sav_score'))} |" ) if split: + sodhita_total = sum(sodhita_scores.values()) if len(sodhita_scores) == 12 else '-' lines.append( - f"| 合计 | - | {sum(parts[0] for parts in splits)} | " + f"| 合计 | - | {sodhita_total} | {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')) + if bav and sodhita_bav: + signs = globals().get('SIGNS', []) + lines.extend([ + ('Sodhita BAV (after Trikona and Ekadhipatya reduction):' if english else '净化后 BAV(三方净化 + 单宫主净化):'), + '', + '| Planet | Sodhita total | Aries | Taurus | Gemini | Cancer | Leo | Virgo | Libra | Scorpio | Sagittarius | Capricorn | Aquarius | Pisces |', + '|--------|---------------|-------|--------|--------|--------|-----|-------|-------|---------|-------------|-----------|----------|--------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): + reduced = _list(sodhita_bav.get(planet)) + if len(reduced) != 12: + continue + cells = ' | '.join(_cell(value) for value in reduced) + lines.append(f"| {_cell(_token(planet))} | {_cell(sum(reduced))} | {cells} |") + lines.extend(['', 'Raw BAV:' if english else '原始 BAV:', '']) if bav: signs = globals().get('SIGNS', []) lines.extend([ diff --git a/scripts/research/refresh_density_fixture_ashtakavarga.py b/scripts/research/refresh_density_fixture_ashtakavarga.py new file mode 100644 index 00000000..98cc0b10 --- /dev/null +++ b/scripts/research/refresh_density_fixture_ashtakavarga.py @@ -0,0 +1,46 @@ +"""Refresh one leaf of the report density fixture from the real engine (BUG-1209). + +`frontend/tests/fixtures/report-density-fictional-engine.json` is a 2026-09-23 +real-engine snapshot without a capture script. The Ashtakavarga reduction moved +to the standard Trikona -> Ekadhipatya chain, so only +`worksheets.strengths_and_scores.ashtakavarga` is recaptured here, from the +fixture's own recorded input (fictional, 2000-01-01 12:00 UT, 0/0); every other +leaf stays byte-identical. + + PYTHONHASHSEED=0 python3 scripts/research/refresh_density_fixture_ashtakavarga.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "scripts")] + +from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading # noqa: E402 +from scripts.professional_report_reference import _export_args # noqa: E402 + +FIXTURE = ROOT / "frontend/tests/fixtures/report-density-fictional-engine.json" + + +def main() -> int: + text = FIXTURE.read_text(encoding="utf-8") + fixture = json.loads(text) + case = dict(fixture["fixtureProvenance"]["input"]) + case.setdefault("age", int(case["target_year"]) - int(case["year"])) + packet = build_professional_report_reference_packet(cmd_full_reading(type("Args", (), dict(case))()), _export_args(dict(case)), []) + fixture["worksheets"]["strengths_and_scores"]["ashtakavarga"] = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"] + fixture["fixtureProvenance"]["refreshed"] = { + "leaf": "worksheets.strengths_and_scores.ashtakavarga", + "reason": "BUG-1209 standard Sodhita chain (TASK-astrologer-rulings-batch1-20261003 T7)", + "generator": "scripts/research/refresh_density_fixture_ashtakavarga.py", + } + indent = 2 if text.startswith("{\n ") else None + FIXTURE.write_text(json.dumps(fixture, ensure_ascii=False, indent=indent) + ("\n" if text.endswith("\n") else ""), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_all.py b/tests/run_all.py index 0a432762..32d1522b 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -14,7 +14,11 @@ def test(name): # === 模块导入测试 === @test("ashtakavarga import") -def t1(): from ashtakavarga import calc_ashtakavarga, calc_prastara_av, calc_sodhita_av +def t1(): + # BUG-1209: calc_sodhita_av (Sun/Mars/Saturn removal, mislabelled BPHS) was removed; + # the standard chain lives in ashtakavarga_shodhana. Was: imported calc_sodhita_av. + from ashtakavarga import calc_ashtakavarga, calc_prastara_av # noqa: F401 + from ashtakavarga_shodhana import sodhita_chain # noqa: F401 @test("shadbala import") def t2(): from shadbala import calc_shadbala @@ -354,16 +358,18 @@ def t56(): @test("Sodhita less or equal to original") def t57(): - from ashtakavarga import calc_ashtakavarga, calc_sodhita_av + # BUG-1209: standard Trikona -> Ekadhipatya chain (was calc_sodhita_av, <= 8 per cell). + from ashtakavarga import calc_ashtakavarga + from ashtakavarga_shodhana import sodhita_chain s = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] p = {} for i,(pn,d) in enumerate([('Sun',15),('Moon',75),('Mars',220),('Mercury',55),('Jupiter',310),('Venus',350),('Saturn',180)]): p[pn] = {'sign':s[int(d/30)%12],'degree':d} av = calc_ashtakavarga(p, 0) - sodhita = calc_sodhita_av(av['bav'], p, 0) + sodhita = sodhita_chain(av['bav'], p) for pn in sodhita['sodhita_bav']: for i in range(12): - assert sodhita['sodhita_bav'][pn][i] <= 8, f"Sodhita {pn}[{i}] should <= 8" + assert sodhita['sodhita_bav'][pn][i] <= av['bav'][pn]['bindus'][i], f"Sodhita {pn}[{i}] should <= raw" # ── Dasha system tests ── @test("Vimshottari remaining years positive") diff --git a/tests/test_ashtakavarga_sodhita_chain.py b/tests/test_ashtakavarga_sodhita_chain.py new file mode 100644 index 00000000..a7ff3625 --- /dev/null +++ b/tests/test_ashtakavarga_sodhita_chain.py @@ -0,0 +1,129 @@ +"""Standard Ashtakavarga reduction chain for display (BUG-1209, ruling 乙8). + +BAV -> Trikona Shodhana -> Ekadhipatya Shodhana per row -> Sodhita SAV (sum of +the reduced rows). Checked against the PVR book chart used by PyJHora +(`jhora.horoscope.chart.ashtakavarga`, Exercise 22 / Chart 7 BAV) for the +Trikona step, rule by rule for Ekadhipatya, and live on a fictional chart. +The removed `calc_sodhita_av` (Sun / Mars / Saturn contributions subtracted, +labelled "BPHS标准") is gone; transit scoring keeps the raw bindus. +""" + +from __future__ import annotations + +import pytest + +import scripts.ashtakavarga as ashtakavarga +from scripts.ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ashtakavarga +from scripts.ashtakavarga_shodhana import attach_house_values, occupied_signs, sodhita_chain + +# PVR "Vedic Astrology: An Integrated Approach", Exercise 22 / Chart 7 BAV +# (Sun..Saturn rows, Aries..Pisces), as quoted in PyJHora 4.8.7. +PVR_CHART_7_BAV = [ + [4, 2, 3, 4, 6, 5, 5, 3, 2, 6, 6, 2], + [6, 3, 5, 3, 5, 5, 6, 3, 3, 4, 4, 2], + [3, 2, 3, 4, 2, 5, 4, 3, 3, 4, 3, 3], + [4, 6, 4, 3, 4, 7, 4, 5, 6, 3, 5, 3], + [4, 4, 3, 5, 6, 5, 6, 4, 6, 4, 3, 6], + [3, 5, 5, 4, 6, 2, 3, 6, 5, 2, 7, 4], + [3, 2, 2, 3, 5, 6, 3, 4, 1, 3, 6, 1], +] + + +def _trikona_by_rules(row: list[int]) -> list[int]: + out = row[:] + for start in range(4): + trine = [start, start + 4, start + 8] + values = [row[index] for index in trine] + if 0 in values: + continue + if len(set(values)) == 1: + for index in trine: + out[index] = 0 + continue + low = min(values) + for index in trine: + out[index] -= low + return out + + +def _bav(rows: list[list[int]]) -> dict: + return {name: {"bindus": row} for name, row in zip(SEVEN_PLANETS, rows)} + + +def test_old_mislabelled_variant_is_removed() -> None: + assert not hasattr(ashtakavarga, "calc_sodhita_av") + assert "BPHS标准" not in (ashtakavarga.__doc__ or "") + + +def test_trikona_step_on_the_pvr_book_bav() -> None: + chain = sodhita_chain(_bav(PVR_CHART_7_BAV), {}) + for name, row in zip(SEVEN_PLANETS, PVR_CHART_7_BAV): + assert chain["trikona_bav"][name] == _trikona_by_rules(row), name + + +def test_trikona_step_matches_pyjhora_when_installed() -> None: + jhora = pytest.importorskip("jhora.horoscope.chart.ashtakavarga") + expected = jhora._trikona_sodhana([row[:] for row in PVR_CHART_7_BAV] + [[0] * 12]) + chain = sodhita_chain(_bav(PVR_CHART_7_BAV), {}) + assert [chain["trikona_bav"][name] for name in SEVEN_PLANETS] == [list(row) for row in expected[:7]] + + +@pytest.mark.parametrize("first,second,occupied,expected", [ + (3, 5, set(), [3, 3]), # both vacant, different -> both the lower + (4, 4, set(), [0, 0]), # both vacant, equal -> both zero + (2, 5, {"A"}, [2, 2]), # vacant one higher -> occupied value + (5, 2, {"A"}, [5, 0]), # vacant one lower -> zero + (3, 5, {"A", "B"}, [3, 5]), # both occupied -> unchanged + (0, 5, set(), [0, 5]), # a zero -> unchanged +]) +def test_ekadhipatya_rules_on_a_pair(first, second, occupied, expected) -> None: + # Pair Capricorn (9) / Aquarius (10); Mars, Mercury sit there when occupied. + row = [0] * 12 + row[9], row[10] = first, second + rows = [row[:] for _ in SEVEN_PLANETS] + planets = {"Sun": {"sign": "Aries"}} + if "A" in occupied: + planets["Mars"] = {"sign": "Capricorn"} + if "B" in occupied: + planets["Mercury"] = {"sign": "Aquarius"} + # A trine with a zero is left alone by Trikona, so only Ekadhipatya acts here. + chain = sodhita_chain(_bav(rows), planets) + assert chain["sodhita_bav"]["Sun"][9:11] == expected + + +def test_nodes_and_lagna_do_not_occupy() -> None: + planets = {"Rahu": {"sign": "Capricorn"}, "Ketu": {"sign": "Cancer"}, "Moon": {"sign": "Leo"}} + assert occupied_signs(planets) == {SIGNS.index("Leo")} + + +def test_live_chart_chain_and_house_values() -> None: + planets = {name: {"sign": SIGNS[(index * 5) % 12]} for index, name in enumerate(SEVEN_PLANETS)} + result = calc_ashtakavarga(planets, 3) + chain = sodhita_chain(result["bav"], planets) + assert chain["status"] == "executed" + reduced = chain["sodhita_bav"] + for name in SEVEN_PLANETS: + assert all(0 <= value <= raw for value, raw in zip(reduced[name], result["bav"][name]["bindus"])) + scores = chain["sodhita_sav"]["scores"] + assert [scores[sign] for sign in SIGNS] == [sum(reduced[name][index] for name in SEVEN_PLANETS) for index in range(12)] + assert chain["sodhita_sav"]["total"] == sum(scores.values()) + assert chain["raw_sav"]["scores"] == result["sav"]["scores"] + attach_house_values(result["house_scores"], chain) + for row in result["house_scores"].values(): + assert row["sav_sodhita"] == scores[row["sign"]] + assert row["sav_score"] == result["sav"]["scores"][row["sign"]] + + +def test_consultation_layer_carries_the_chain() -> None: + from scripts.capture_report_blocked_repairs_golden import _handler + + planets = {name: {"lon": (index * 47.0) % 360} for index, name in enumerate(SEVEN_PLANETS)} + response = _handler()._compute_ashtakavarga({"planets": planets, "ascendant": {"sign_idx": 2}}) + result = response["result"] + assert result["sodhita"]["status"] == "executed" + assert all("sav_sodhita" in row for row in result["house_scores"].values()) + assert response["rule_variants"]["available"][2]["source"] == "scripts/ashtakavarga_shodhana.py" + + +def test_missing_rows_block() -> None: + assert sodhita_chain({}, {})["status"] == "blocked" diff --git a/tests/test_report_chart_blank_columns.py b/tests/test_report_chart_blank_columns.py index 527d63b3..5fd7642b 100644 --- a/tests/test_report_chart_blank_columns.py +++ b/tests/test_report_chart_blank_columns.py @@ -93,25 +93,31 @@ def test_declination_table_has_no_kranti_column(editions) -> None: def test_ashtakavarga_house_table_shows_three_real_columns(editions) -> None: zh, en, packet = editions expected_sav = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sav"]["scores"] + # BUG-1209 (乙8): a Sodhita SAV column comes first and the raw columns are + # labelled Raw. Was: ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"] + # with the raw cells at indexes 2..4 (now 3..5). + sodhita = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sodhita"]["sodhita_sav"]["scores"] for markdown, heading, header, total in ( - (zh, "Ashtakavarga 完整宫位分数", ["House", "Sign", "SAV", "上升 BAV", "SAV + 上升"], "合计"), - (en, "Ashtakavarga Full House Scores", ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"], "Total"), + (zh, "Ashtakavarga 完整宫位分数", ["House", "Sign", "Sodhita SAV", "Raw SAV", "上升 BAV", "Raw SAV + 上升"], "合计"), + (en, "Ashtakavarga Full House Scores", ["House", "Sign", "Sodhita SAV", "Raw SAV", "Lagna BAV", "Raw SAV + Lagna"], "Total"), ): rows = _table_after(markdown, heading) assert rows[0] == header body, totals = rows[1:13], rows[13] for row in body: - sav, lagna, full = (int(cell) for cell in row[2:]) + sav, lagna, full = (int(cell) for cell in row[3:]) assert sav + lagna == full - assert totals == [total, "-", str(EXPECTED_SAV_TOTAL), str(BAV_TOTALS["Lagna"]), str(EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"])] + assert int(row[2]) <= sav + assert totals == [total, "-", str(sum(sodhita.values())), str(EXPECTED_SAV_TOTAL), str(BAV_TOTALS["Lagna"]), str(EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"])] # SAV equals the per-sign SAV table, Lagna BAV the Lagna bindus, and the last # column the engine's `house_scores_full` total that rectification reads. ashtakavarga = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"] full = {row["sign"]: row["sav_score"] for row in ashtakavarga["house_scores_full"].values()} for row in _table_after(en, "Ashtakavarga Full House Scores")[1:13]: - assert int(row[2]) == expected_sav[row[1]] - assert int(row[3]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])] - assert int(row[4]) == full[row[1]] + assert int(row[2]) == sodhita[row[1]] + assert int(row[3]) == expected_sav[row[1]] + assert int(row[4]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])] + assert int(row[5]) == full[row[1]] def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> None: @@ -120,8 +126,10 @@ def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> Non rows = _table_after(_pl9_export_markdown_for_edition(packet, "reader_main"), "Ashtakavarga 完整宫位分数") assert len(rows) == 13, "no total row without the split" for row in rows[1:]: - assert row[2:4] == ["-", "-"] - assert row[4].isdigit() + # BUG-1209: raw SAV / Lagna BAV moved from [2:4] to [3:5]; Sodhita at [2]. + assert row[2].isdigit() + assert row[3:5] == ["-", "-"] + assert row[5].isdigit() def test_chart_section_carries_the_divisional_caveat(editions) -> None: