From ae18952e5188de867e3a56d361911abc034f3db6 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 3 Oct 2026 09:11:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(report,chart):=20blank=20columns=20?= =?UTF-8?q?=E2=80=94=20drop=20Kranti,=20split=20Ashtakavarga=20house=20SAV?= =?UTF-8?q?,=20ascendant=20nakshatra,=20engine=20varga=20dignity,=20no=20d?= =?UTF-8?q?uplicate=20charts=20(BUG-1199..1203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- CHANGELOG.md | 9 + docs/BUG_HISTORY.md | 72 +++ docs/research/pre_work_error_ledger.md | 6 + ...ESS-report-chart-blank-columns-20261003.md | 57 ++ docs/tasks/README.md | 2 +- .../report-chart-blank-columns-20261003.md | 21 + frontend/DESIGN.md | 6 +- .../components/chart-page/chart-vedic-tab.tsx | 3 +- .../personal-report/personal-report-page.tsx | 2 +- .../report-calculation-charts.tsx | 24 +- frontend/src/lib/chart-view-contract.ts | 3 +- frontend/src/lib/chart-view-mapper.ts | 20 +- frontend/src/lib/chart-view-varga.ts | 50 +- frontend/src/lib/report-chart-block.ts | 13 + frontend/src/lib/report-export-blocks.ts | 5 +- frontend/src/lib/vedic-chart-tables.json | 48 +- frontend/tests/chart-varga-table.test.tsx | 60 +- .../tests/chart-view-varga-dignity.test.ts | 29 +- .../tests/fixtures/chart-view-golden.json | 547 ++++++++++++------ .../tests/report-calculation-charts.test.tsx | 48 ++ scripts/jyotish_api_server.py | 4 +- scripts/jyotish_engine.py | 45 +- scripts/pl9_reader_english_terms.py | 4 +- scripts/pl9_reader_export.py | 47 +- scripts/varga_dignity.py | 24 + tests/test_api_server_security.py | 24 + .../test_chart_page_vedic_tables_contract.py | 54 +- tests/test_report_chart_blank_columns.py | 130 +++++ 28 files changed, 1011 insertions(+), 346 deletions(-) create mode 100644 docs/tasks/PROGRESS-report-chart-blank-columns-20261003.md create mode 100644 docs/testing/report-chart-blank-columns-20261003.md create mode 100644 frontend/tests/report-calculation-charts.test.tsx create mode 100644 scripts/varga_dignity.py create mode 100644 tests/test_report_chart_blank_columns.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0305e452..d2397cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-03 — 报告和星盘页不再出现「一整列横杠」;八分法宫位表的 SAV 数字改正 + +- 个人报告「赤纬与速度」表删掉一直为空的 Kranti 列(本站没有它的独立算法,赤纬列已是真实值)(BUG-1199)。 +- 个人报告「Ashtakavarga 完整宫位分数」表:以前标着 SAV 的那一列其实是「含上升合计」(12 宫合计 386),已改正为三列——SAV(合计 337)、上升贡献(合计 49)、含上升合计(386),末行加合计。生时校正使用的分数不变(BUG-1200)。 +- 星盘页本命表上升那一行补上星宿、宿主、pada(例:狮子 14°32′ = Purva Phalguni 第 1 足、宿主金星)(BUG-1201)。 +- 星盘页分盘表的「庙旺」列改为与本命「状态」列同一套十档说法(入旺、本垣、入庙、极友、入友、中性、入敌、极敌、落陷、落陷取消),以前只写入旺 / 自宫 / 落陷、其余一律「—」(BUG-1202)。 +- 报告末尾不再重复画一遍正文已有的分盘;「分盘对出生时间敏感」这句挪到正文分盘标题下(BUG-1203)。旧报告正文里没有分盘图的,末尾照旧补上。 +- Skill 版本不变;不改数据库结构。新生成的报告才有新表格,旧报告内容不变。 + ## 2026-10-02 — 生时校正至少要 4 件有年月的经历、覆盖 3 个方面才给时间范围;上游同步第五轮(燃烧、诅咒格措辞、Badhaka 等) - 生时校正:以前 3 件有年月的经历、2 个方面(例如上学和工作)就会给出生时间范围;现在至少要 4 件、覆盖 3 个方面(例如再加一件搬家或感情)才开始给范围和选择题。还不够时,提示会写清楚还差几件、还缺哪类(例:「再来两件记得大概年月的事,其中至少一件不是上学或工作的,就能开始筛」),不再一律写「再来一件」(BUG-1193)。打分方法不变,算法版本不变;以前的校正记录照常打开。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ba3df599..61c8930e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16134,3 +16134,75 @@ - 防复发:同上。 - 相关记录:BUG-1195。 - 修复版本:`codex/upstream-sync5-20261002`(未推送)。 + +## BUG-1199 | 报告「Declination / Kranti / Speed」表的 Kranti 列整列为「-」 + +- 状态:resolved(分支验证;待部署 staging) +- 首次发现 / 最近更新:2026-10-03 / 2026-10-03 +- 影响面:`scripts/pl9_reader_export.py` p33 表(中英两版报告正文与目录)。 +- 现象:产品真机截图,个人报告「Declination / Kranti / Speed」表七行 Kranti 全是 `-`,像没算出来。 +- 触发条件:任何一份报告。 +- 根因:reader 读 `strengths.kranti_local.planets[*].kranti_degrees`,全仓没有任何 producer;引擎 materials `kranti` 自标 `missing_in_local / blocked`(no independent Kranti producer)。表照样留了这一列。 +- 修复:产品选删列:表改为 `Planet | Degree | Declination | Speed`,标题 `Declination / Speed`。不按古典公式补算(无 PL9 原值可对照,Declination 已是真实赤纬)。materials `kranti: blocked` 审计口径与 `full_report_quality_gate.RESTRICTED_MATERIAL_IDS` 不变。 +- 验证:`tests/test_report_chart_blank_columns.py::test_declination_table_has_no_kranti_column`(中英两版:无 Kranti、四列、无 `-`)。 +- 防复发:没有 producer 的字段不进读者表格;要加列先有来源。 +- 相关记录:BUG-1200~1203(同一张截图)。 +- 复发自:无 +- 修复版本:`codex/report-chart-blank-columns-20261003` + +## BUG-1200 | 报告「Ashtakavarga 完整宫位分数」把含上升合计标成 SAV,另两列为「-」 + +- 状态:resolved(分支验证;待部署 staging) +- 首次发现 / 最近更新:2026-10-03 / 2026-10-03 +- 影响面:`scripts/pl9_reader_export.py` p53 表(数据源 `scripts/ashtakavarga.py::_map_to_houses_values`,未改)。 +- 现象:表头「House | Sign | SAV | BAV/承接 | Full with 上升」,后两列全 `-`;「SAV」列 12 宫合计 386,与同页「Sign | SAV score」表(合计 337)对不上。 +- 触发条件:任何一份报告。 +- 根因:`_map_to_houses_values(full_sav, …)` 只写 `sign` + `sav_score`,传入的是 `full_sav = sav + lagna_sav`(337 + 49);reader 用 `row.get('sav') or row.get('sav_score')` 取到含上升合计标成 SAV,`bav` / `full_with_lagna` 字段不存在。**数字标错,不只是缺列**。 +- 修复:在 reader 拆分,**`ashtakavarga.py` 不改**——它在生时校正冻结身份(`sealed_holdout_rerun.PRODUCTION_FILES`)里,最初按任务书改它新增字段,快速门 `test_rectification_validation_integrity_gate` 4 条转红(ERR-110);改为 reader 用引擎已有的按星座 SAV(`sav.scores`)与上升 BAV(`bav.Lagna.bindus`)拆出,且要求 `SAV + 上升 BAV == house_scores_full.sav_score` 逐宫成立才填,否则 SAV / 上升两列写 `-`、不出合计行,绝不把合计填进 SAV 列。`sav_score` 语义与数值不变(生时校正在读)。表改为 `House | Sign | SAV | Lagna BAV | SAV + Lagna`(中文版「上升 BAV」「SAV + 上升」),末行合计。 +- 验证:`tests/test_report_chart_blank_columns.py`:每行 SAV + 上升 BAV = 最后一列、合计 337 / 49 / 386(中英两版);SAV 列 = 按星座 SAV、上升列 = Lagna bindus、末列 = `house_scores_full.sav_score`;删掉 Lagna BAV 时 SAV / 上升两列为 `-` 且无合计行。验证完整性门禁与封存评测新鲜度测试保持全绿(冻结身份未变)。 +- 防复发:报告表格列只读语义明确的字段,不用 `or` 链兜到别的口径。 +- 相关记录:BUG-1199。 +- 复发自:无 +- 修复版本:`codex/report-chart-blank-columns-20261003` + +## BUG-1201 | 星盘页本命表上升行的星宿 / 宿主 / pada 一直是「—」 + +- 状态:resolved(分支验证;待部署 staging) +- 首次发现 / 最近更新:2026-10-03 / 2026-10-03 +- 影响面:`scripts/jyotish_engine.py::compute_chart_data`(`/api/chart` 的 `ascendant`)、`frontend` 星盘页 D1 / 月亮盘表上升行。 +- 现象:上升行星宿、宿主、pada 三格为「—」,九曜都有。 +- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。 +- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。 +- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。 +- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。 +- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。 +- 复发自:无 +- 修复版本:`codex/report-chart-blank-columns-20261003` + +## BUG-1202 | 星盘页分盘表「庙旺」只做三档,敌宫 / 中性等一律「—」 + +- 状态:resolved(分支验证;待部署 staging) +- 首次发现 / 最近更新:2026-10-03 / 2026-10-03 +- 影响面:`/api/varga_full`(新模块 `scripts/varga_dignity.py`)、`jyotish_engine.varga_dignity_level`、`frontend/src/lib/chart-view-{varga,mapper,contract}.ts`、`vedic-chart-tables.json`。 +- 现象:D9 表只有入旺 / 自宫 / 落陷有字(太阳在金牛=敌宫、月亮在摩羯=中性都是「—」),产品以为没算。 +- 根因:BUG-1076 / 1099 有意只做三档,前端自带一份 `vargaDignity` 表;引擎 Vimsopaka 路径早有完整判定没接过来。 +- 修复(产品 10-03 拍板,**有限推翻 BUG-1076「三档庙旺」与 BUG-1099「三档庙旺保留」**;BUG-1099 能力声明规则不变):抽 `varga_dignity_level(planet, varga_planets)`(暂时关系取该分盘自身位置,与 Vimsopaka 一致),Vimsopaka 与 `/api/varga_full` 共用;`/api/varga_full` 每星加 `dignity_level`;前端只校验并用 D1 状态列同一措辞(`DIGNITY_LABELS`)显示;删除前端 `vargaDignity` 及只为它服务的四张表。旧缓存无等级时整列隐藏,不自算。 +- 验证:Vimsopaka 抽函数前后同机 A/B(`PYTHONHASHSEED=0`,3 张虚构盘 `cmd_full_reading` 的 vimsopaka / varga_full / jaimini / 行星 / 上升)逐字节相同;`tests/test_api_server_security.py::test_varga_full_standard_divisions_carry_the_vimsopaka_dignity_level`(与 `varga.calc_all_vargas` 的 Vimsopaka 输入逐星同座同级);`tests/test_chart_page_vedic_tables_contract.py`(真实 TS mapper 投影 = 引擎等级,golden 覆盖入庙 / 落陷 / 中性 / 入敌 / 入友;措辞表 = `DIGNITY_LABELS`);`frontend/tests/chart-varga-table.test.tsx`、`chart-view-varga-dignity.test.ts`。 +- 防复发:分盘庙旺只有引擎一套;页面不得再写第二套判定。 +- 相关记录:BUG-1076、BUG-1099。 +- 复发自:无(范围扩展,不是回归)。 +- 修复版本:`codex/report-chart-blank-columns-20261003` + +## BUG-1203 | 报告末尾「分盘核对」把正文已画过的分盘再画一遍 + +- 状态:resolved(分支验证;待部署 staging) +- 首次发现 / 最近更新:2026-10-03 / 2026-10-03 +- 影响面:`frontend/src/components/personal-report/report-calculation-charts.tsx`、`personal-report-page.tsx`、`src/lib/report-chart-block.ts`、`report-export-blocks.ts`、`scripts/pl9_reader_export.py`(提示句)。 +- 现象:正文「本命与分盘北印度图盘」已画全部分盘,正文后又出现「分盘核对」+ 提示句 + D1、D2… 第二遍,在目录外、样式散。 +- 根因:页面在 Markdown 正文后无条件挂 `ReportCalculationCharts`;导出早已按正文围栏去重,页面没用同一规则。 +- 修复:抽 `fencedReportChartIds`,页面与导出共用;页面只画正文没有围栏的结构化分盘(新报告整段消失,老报告缺哪张补哪张)。提示句由引擎写到正文分盘标题下(英文版走术语表),组件里的提示删除。 +- 验证:`frontend/tests/report-calculation-charts.test.tsx`(真实 golden 正文 → 整段不渲染;无围栏正文 → 照旧且不带提示;页面与导出跳过同一批盘);导出原有测试(含冻结哈希)全绿;`tests/test_report_chart_blank_columns.py::test_chart_section_carries_the_divisional_caveat`(中英)。 +- 防复发:同一张盘页面只画一次;页面与导出共用去重函数。 +- 相关记录:BUG-607 / 616(报告图盘渲染)。 +- 复发自:无 +- 修复版本:`codex/report-chart-blank-columns-20261003` diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 48b7074e..c7803863 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -472,3 +472,9 @@ Prevention: `frontend/tests/runtime-sources-no-test-imports.test.ts`;研究 ha `57782aea` 改 `scripts/functional_benefics.py`(功能吉凶 v2)让原生打分同输入出不同分(reported-offset 900 例里 230 例变化),但该文件不在 `sealed_holdout_rerun.py` `PRODUCTION_FILES` 和 dataset `frozen_scoring.files` 里,`tests/test_rectification_validation_integrity_gate.py` 全绿;随后只把 `scoring_service.py` 的 `ALGORITHM_VERSION` 从 scoring-9 改成 scoring-10,同一门禁 8 条失败。冻结身份是显式审过的文件清单,不是传递依赖,清单外的打分输入会静默漂移。 Prevention: 打分路径新增或改动输入模块时,先查它在不在两份清单里;`functional_benefics.py` 已补进 `PRODUCTION_FILES` 并由 `test_functional_roles_are_part_of_the_production_identity` 锁住;改动后按 ERR-110 重新冻结(本次标签 `functional_v2_2026_10_02`)。关联 BUG-1181、ERR-110。 + +## ERR-115 | 任务书指定给冻结文件 `ashtakavarga.py` 加展示字段,快速门验证完整性 4 条转红 | observed 2026-10-03 + +`TASK-report-chart-blank-columns-20261003` T2 写的是「`_map_to_houses_values` 增字段」,`scripts/ashtakavarga.py` 在 `sealed_holdout_rerun.PRODUCTION_FILES` 里,只加字段、分数不变也让 `test_rectification_validation_integrity_gate` 4 条与 `test_sealed_holdout_contract_freshness` 3 条失败(ERR-110 同型)。改为在 `pl9_reader_export.py` 用引擎已有字段拆分,冻结文件零改动,门禁恢复全绿,无需重新冻结。 + +Prevention: 写任务书时(不只是执行时)就对照 `PRODUCTION_FILES` 与 dataset `frozen_scoring.files`;只为展示的字段优先在消费层(reader / mapper)从已有数据推出,不进冻结文件。关联 ERR-110、BUG-1200。 diff --git a/docs/tasks/PROGRESS-report-chart-blank-columns-20261003.md b/docs/tasks/PROGRESS-report-chart-blank-columns-20261003.md new file mode 100644 index 00000000..ee202c92 --- /dev/null +++ b/docs/tasks/PROGRESS-report-chart-blank-columns-20261003.md @@ -0,0 +1,57 @@ +# PROGRESS · 报告与星盘页的「空列」(2026-10-03) + +- 任务书:`docs/tasks/TASK-report-chart-blank-columns-20261003.md`(`3bbc2daa`) +- 基线:`origin/staging` `3bbc2daa`(任务书写的 `1af34880` + 任务书本身,代码相同) +- 分支 / worktree:`codex/report-chart-blank-columns-20261003` / `.worktrees/report-chart-blank-columns-20261003` +- 执行方式:产品 10-03 说「请你执行」,Claude 直接实现并独立验收。 +- BUG:1199~1203(开工核对最大号 1198) + +## 完成情况 + +| 任务 | 结果 | 要点 | +| --- | --- | --- | +| T1 Kranti 删列(BUG-1199) | 完成 | `pl9_reader_export.py` p33 表四列;materials `kranti: blocked` 与质量门 `RESTRICTED_MATERIAL_IDS` 未动 | +| T2 八分法三列(BUG-1200) | 完成,**实现方式偏离任务书** | 见下「偏离」 | +| T3 上升星宿(BUG-1201) | 完成 | `_nakshatra_fields` 九曜 / 计都 / 上升共用;`cache_schema_version` 4→5;指纹 / 绑定核查:只用出生资料(`report-birth-binding`、`secondary-page-data` 的 `profileFingerprint`),报告封面哈希是报告正文;均不受影响 | +| T4 分盘十档庙旺(BUG-1202) | 完成 | `jyotish_engine.varga_dignity_level`(Vimsopaka 与 `/api/varga_full` 共用);新模块 `scripts/varga_dignity.py`,API 主文件只改一行(行数 10923 不变);前端删 `vargaDignity` 与四张表,旧缓存无等级整列隐藏 | +| T5 末尾分盘去重 + 提示前移(BUG-1203) | 完成 | `fencedReportChartIds` 页面与导出共用;提示句由引擎写在 p6-p17 标题下(中英) | + +未用让步顺序。 + +## 偏离任务书:T2 不改 `ashtakavarga.py` + +任务书写「`_map_to_houses_values` 增字段」。照做后快速门红 4 条(`test_rectification_validation_integrity_gate`)、`test_sealed_holdout_contract_freshness` 红 3 条:该文件在生时校正冻结身份里(ERR-110 同型;台账新增 ERR-115,是任务书本身的疏漏)。改为 reader 用 `sav.scores` 与 `bav.Lagna.bindus` 拆分,并要求逐宫 `SAV + 上升 BAV == house_scores_full.sav_score` 才填;`ashtakavarga.py` 已回到基线字节,门禁恢复全绿,无需重新冻结。红线 1(`sav_score` 不变)照样满足。 + +## 既有断言改动(原值 / 新值 / 原因) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/chart-varga-table.test.tsx` D1 逐字节基线 | 表 == `chart-d1-table-baseline.html` | 表 == 基线把上升行唯一一组「— — — —」换成「Uttara Phalguni / ☉ / 1 / —」;并断言基线里这样的全横杠行只有一处 | BUG-1201:上升三格现在有引擎值;其余逐字节不变 | +| 同文件 D9 行 | `dignity == "own"` / `"debilitated"`;markup `>自宫<`、`>落陷<` | `"OWN_SIGN"` / `"DEBILITATED"`;markup `>入庙(Own Sign)<`、`>落陷(Debilitated)<`,另加入友 / 中性 / 入敌 / 极敌四行 | BUG-1202:十档引擎等级、D1 状态列措辞 | +| 同文件「null 不关列」 | tbody 不含 `同位/入旺/自宫/落陷` | tbody 不含 `同位` 与 `(Exalted|Own Sign|Debilitated|Neutral)` | 同上,标签换了;断言强度不变 | +| `frontend/tests/chart-view-varga-dignity.test.ts` | `vargaDignity` 三档优先级 8 条断言 | `vargaDignityLevel` 接受十档、拒绝旧三档值与非字符串;标签与 D1 措辞一致、键集合 = 十档 | 前端自算函数已删除(不留第二套判定) | +| `tests/test_chart_page_vedic_tables_contract.py` | `test_tables_equal_engine_constants`、`test_every_planet_sign_pair_matches_engine_dignity_level`(钉三档表) | `test_dignity_labels_equal_engine_status_wording`(措辞表 = `DIGNITY_LABELS`,旧四表已不存在)+ `test_d9_projected_dignity_is_the_engine_level`(真实 TS mapper 投影 = 引擎等级,golden 覆盖入庙 / 落陷 / 中性 / 入敌 / 入友) | 同上 | + +golden:`frontend/tests/fixtures/chart-view-golden.json` 只新增字段(上升三项、每颗分盘星 `dignity_level`),原值零改动;重录方式写在 `sourceCommand`(`_nakshatra_fields(ascendant.lon)`、`annotate_varga_dignity` 作用于已存位置,即 `/api/chart`、`/api/varga_full` 现在的调用)。 + +报告大 fixture(`report-reader-main-fictional*.json`、`report-density-fictional-reader.json`)是 9 月真实输出,里面仍是旧表;前端没有断言碰这两张表,未重录(重录会混入其后大量无关引擎变化)。新表由 Python 测试现场渲染断言。 + +## 验收 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(126 warning 均为既有;改动文件单独跑 eslint 0 问题) | +| `npm test`(Node 22) | 4927 项 / 失败 24;基线 `3bbc2daa` 4921 项 / 失败 24;失败清单逐名一致,全为无 Docker 的数据库 / 部署套件;新增 6 项 | +| `next build` | 通过,`/` 为 `○ Static` | +| 首屏 gzip(`index.html` 引用的 29 个脚本 / 样式 gzip-9 求和) | 基线 625,123 → 本分支 626,061,**+0.15%**。基线构建在最后 standalone 复制步因整机磁盘满(ENOSPC)退出,编译与页面生成已完成,`index.html` 与引用文件齐全,数字有效 | +| Python 定向 | `test_report_chart_blank_columns`(新 12 项)、`test_chart_page_vedic_tables_contract`、`test_api_server_security`(新增 1 项)、`test_report_english_edition`、`test_report_reader_main`、`test_report_chart_block`、`test_ashtakavarga_invariants`、`test_api_server_growth_contract`、`test_rectification_validation_integrity_gate`、`test_sealed_holdout_contract_freshness` 全绿 | +| 快速门 `run_quality_gate.py --profile quick` | Python 段 1047 passed / 1 skipped;最后一步 `npm test` 因上述 24 项无 Docker 失败而整体判红,与基线同 | +| 既有红 | `test_dynamic_rectification_fact_priority::test_historical_event_priority_preserves_vimshottari_actor_difference` 在基线 `3bbc2daa` 同样失败(`selection_priority 0.0`),与本轮无关,未处理 | +| Vimsopaka 抽函数 A/B | `PYTHONHASHSEED=0`,3 张虚构盘 `cmd_full_reading` 的 vimsopaka / varga_full / jaimini / 行星 / 上升(除新增星宿字段)基线与本分支逐字节相同 | + +## 环境缺口 + +- 无登录态与 Chrome:真机走查留给产品,清单 `docs/testing/report-chart-blank-columns-20261003.md`。 +- 无 Docker:数据库 / 部署套件 24 项按基线逐名比对。 +- 整机磁盘曾满(他会话占用):一次快速门因 `/tmp` 建不了目录全部 error(未计入结论),腾出自己的构建产物后重跑得到上表结果。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 62e68a63..2e86521c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -412,4 +412,4 @@ | `TASK-yoga-lunar-node-consistency-20261002.md` | `PROGRESS-yoga-lunar-node-consistency-20261002.md` | 月亮前后有星格(Sunaphaa/Anaphaa/Duradhara)计入罗睺计都、孤立格(Kemadruma)不计,泰勒/齐达内卡上同时出现互相矛盾的格局;统一为只计五颗真行星 | **已并入** `TASK-upstream-sync4-20261002.md` T5(交付见 `PROGRESS-upstream-sync4-20261002.md`,`f3ed9509` + `0a4e6d0a`,BUG-1187) | 原分支未使用;在 `codex/upstream-sync4-20261002` 交付 | | `TASK-upstream-sync4-20261002.md` | `PROGRESS-upstream-sync4-20261002.md` | 上游同步第四轮(对照 yinduzhanxing `0a6696c5`):婚姻计数删关系次数断语、双重过运相位错一宫、Shadbala 四处计算错、格局列表截断与空回退、月亮/太阳类格局只计五星(吸收月亮单)、Arudha 只留 jaimini 一个来源、定位星循环与未标定概率;BUG 从 1183 起 | **待验收**(子代理直接执行完毕,未推送;追加 F3 太阳最低要求;BUG-1183~1191) | 分支 `codex/upstream-sync4-20261002` | | `TASK-upstream-sync5-20261002.md` | `PROGRESS-upstream-sync5-20261002.md` | 上游同步第五轮:Narayana 默认 K.N. Rao、Chara Karaka 罗睺、燃烧统一、八分法净化、Yogini、诅咒格措辞、Budha-Aditya、Badhaka;生时校正至少 4 件事 3 个领域 | **待验收**(PM 移植 U2–U8 + 子代理 U8 后续 / R1 / 冻结 / 评测 / 回测第七轮,未推送;U1 延后;分数不变不升 scoring-11,policy v3 → v4;BUG-1192~1198) | 分支 `codex/upstream-sync5-20261002` | -| `TASK-report-chart-blank-columns-20261003.md` | `PROGRESS-report-chart-blank-columns-20261003.md` | 报告/星盘页空列:Kranti 删列、八分法宫位表 SAV 标错(实为含上升 386)补三列、上升补星宿、分盘庙旺接引擎十档(有限推翻 BUG-1076/1099 三档)、报告末尾重复分盘去重(BUG-1199~1203);待领取 | +| `TASK-report-chart-blank-columns-20261003.md` | `PROGRESS-report-chart-blank-columns-20261003.md` | 报告/星盘页空列:Kranti 删列、八分法宫位表 SAV 标错(实为含上升 386)补三列、上升补星宿、分盘庙旺接引擎十档(有限推翻 BUG-1076/1099 三档)、报告末尾重复分盘去重(BUG-1199~1203);Claude 直接执行,已合入 staging,待部署核对与真机 | diff --git a/docs/testing/report-chart-blank-columns-20261003.md b/docs/testing/report-chart-blank-columns-20261003.md new file mode 100644 index 00000000..86772b02 --- /dev/null +++ b/docs/testing/report-chart-blank-columns-20261003.md @@ -0,0 +1,21 @@ +# 真机清单 · 报告与星盘页空列(2026-10-03,BUG-1199~1203) + +前提:staging 已部署本轮提交(`/api/health` 的 `deployment.gitCommit` 等于合入 SHA)。用自己的账号,不用别人的资料。 + +## 报告(要**新生成**一份;旧报告内容不会变) + +1. 星盘档案 → 本人 → 生成个人报告,等它完成后打开。 +2. 目录里「强度、关系与 Ashtakavarga 技术页」下应写「Declination / Speed」,不再有 Kranti。点进去:表只有 Planet / Degree / Declination / Speed 四列,没有整列「-」。 +3. 找「Ashtakavarga 完整宫位分数」:五列「House · Sign · SAV · 上升 BAV · SAV + 上升」,每行 SAV + 上升 BAV = 最后一列;最后一行「合计」应是 337 / 49 / 386。SAV 列和上方「Sign | SAV score」表按星座对得上。 +4. 目录「出生资料与图盘 → 本命与分盘北印度图盘」:标题下第一句是「分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。」 +5. 滚到报告最底部:不应再出现「分盘核对」和第二遍 D1 / D2 图。 +6. 右上切「English」:第 2–5 步对应英文版同样成立(表头「Lagna BAV」「SAV + Lagna」,合计行「Total」)。 +7. 导出 → 「图盘」:导出的图盘数量与修复前一样(每张一次)。 +8. 打开一份**以前生成的**报告:能正常打开;若末尾原本有「分盘核对」,它只会在正文缺图时出现。 + +## 星盘页 + +9. 打开「星盘」→ 本命表第一行「上升」:星宿、宿主、pada 三格有值(例:狮子 14°32′ 应为 Purva Phalguni、宿主金星符号、pada 1);状态格仍为「—」。 +10. 切到 D9:「庙旺」列每颗星都有字(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消之一),写法与 D1「状态」列一样;上升行写「不适用」。同位列不变(只有同位的写「同位」)。 +11. 切 D10、D2 各看一眼:庙旺列同样每颗星有字。 +12. 手机宽度(或把窗口拉窄)看 D9 表:庙旺列文字变长后表格可横向滚动,不撑破页面。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f1be14f4..1990cb6e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -653,7 +653,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/ - **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette. - **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged. - **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed. -- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`. +- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`. - **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge. - **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%. - **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px. @@ -1152,7 +1152,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | pada | 1–4,居中 | | 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 | -第一行是上升。上升是盘点不是星体,引擎不返回它的星宿 / pada / 宿主,这三格写「—」而不是填一个看起来合理的值。数字列 `tabular-nums`,符号列与宿主列居中。 +第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。 本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。 @@ -1161,7 +1161,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | 盘型 | 列 | 说明 | | --- | --- | --- | | 本命 | 上面八列 | 不变(与基线渲染逐字节相同) | -| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」,上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | +| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | | 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) | | Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) | | 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 | diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index c074c134..c02cd5e5 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -78,7 +78,8 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) { } /** - * The ascendant is a chart point: the engine returns no nakshatra for it. + * The ascendant is a chart point: nakshatra, lord and pada come from the + * engine (BUG-1201); it has no dignity, so the status cell stays empty. * `house` is 1 except on the Moon chart, which counts from the Moon. */ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) { diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 2e03b29b..f6ae3c06 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -566,7 +566,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */} - {state.calculationCharts && } + {state.calculationCharts && } {factTables && }
diff --git a/frontend/src/components/personal-report/report-calculation-charts.tsx b/frontend/src/components/personal-report/report-calculation-charts.tsx index 216f2ba1..3534ec2a 100644 --- a/frontend/src/components/personal-report/report-calculation-charts.tsx +++ b/frontend/src/components/personal-report/report-calculation-charts.tsx @@ -1,22 +1,34 @@ "use client"; -import { memo } from "react"; +import { memo, useMemo } from "react"; import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract"; +import { fencedReportChartIds } from "@/lib/report-chart-block"; import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg"; import type { ReportLanguage } from "@/lib/report-language"; const COPY = { - zh: { heading: "分盘核对", note: "原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。" }, - en: { heading: "Divisional charts for checking", note: "Raw calculations for checking. Divisional charts are sensitive to birth time and do not by themselves make a conclusion more certain." }, + zh: { heading: "分盘核对" }, + en: { heading: "Divisional charts for checking" }, } as const; -export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, language = "zh" }: { charts: ReportDocumentV2["charts"]; language?: ReportLanguage }) { +/** + * Structured charts the report body did not already draw (BUG-1203): the same + * rule as the export. A current report draws every chart in its body, so this + * renders nothing; an older report without chart fences still gets its charts. + * The birth-time caveat lives under the body's chart heading. + */ +export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, markdown, language = "zh" }: { + charts: ReportDocumentV2["charts"]; + /** The body shown on the page, whose chart fences are already drawn. */ + markdown: string; + language?: ReportLanguage; +}) { const copy = COPY[language]; - const actual = charts.filter(chart => hasRealChartData(chart as ChartV1)); + const drawn = useMemo(() => fencedReportChartIds(markdown), [markdown]); + const actual = charts.filter(chart => hasRealChartData(chart as ChartV1) && !drawn.has(chart.id)); if (!actual.length) return null; return

{copy.heading}

-

{copy.note}

{actual.map(chart =>

{language === "en" ? `${chart.id} chart` : chart.title}

diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 2fcc81eb..28a7a012 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { CHART_BIRTH_FAILURE_REASONS } from "./chart-birth-truth.ts"; import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts"; +import { VARGA_DIGNITY_LEVELS } from "./chart-view-varga.ts"; const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); @@ -82,7 +83,7 @@ export const chartViewVargaRowSchema = z.object({ house: z.number().int().min(1).max(12), natalSign: z.string().min(1), natalSignLabel: z.string().min(1), - dignity: z.enum(["exalted", "own", "debilitated"]).nullable(), + dignity: z.enum(VARGA_DIGNITY_LEVELS).nullable(), vargottama: z.boolean(), retrograde: z.boolean(), }); diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index d8b8c67f..c98c2a09 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -23,7 +23,7 @@ import { signZh, } from "./chart-view-labels.ts"; import { traditionalSymbol } from "./chart-view-symbols.ts"; -import { vargaDignity } from "./chart-view-varga.ts"; +import { vargaDignityLevel } from "./chart-view-varga.ts"; import { transitsFromEngine } from "./ephemeris-view.ts"; import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts"; import { @@ -209,9 +209,8 @@ function houseFromSigns(sign: string, ascSign: string): number { /** * The divisional chart's own rows: the engine's divisional sign, in-varga - * degree and house (counted from the divisional ascendant), plus the D1 sign - * each body came from. Nothing is recomputed here except the sign-dignity - * lookup, whose tables are the engine's (`chart-view-varga.ts`). + * degree, house (counted from the divisional ascendant) and dignity level, + * plus the D1 sign each body came from. Nothing is recomputed here. */ function vargaRows( ascSign: string, @@ -236,7 +235,7 @@ function vargaRows( house: engineHouse !== null && engineHouse >= 1 && engineHouse <= 12 ? Math.trunc(engineHouse) : houseFromSigns(sign, ascSign), natalSign, natalSignLabel: signZh(natalSign), - dignity: vargaDignity(name, sign), + dignity: vargaDignityLevel(planet.dignity_level), vargottama: sign === natalSign, retrograde: natalRetro.get(name) ?? false, }); @@ -323,9 +322,10 @@ function buildVargas( chart: mapped.chart, ...(table ? { ...table, - // A null dignity or false same-sign result is still a valid result. // Availability follows validated source rows, never positive hits. - supportsDignity: table.rows.length > 0, + // The engine gives every body a level; a row without one is an + // older cached payload, and then the column is hidden (BUG-1202). + supportsDignity: table.rows.length > 0 && table.rows.every((row) => row.dignity !== null), supportsVargottama: division === 9 && (table.rows.length > 0 || table.ascendant !== undefined), } : {}), }); @@ -383,9 +383,9 @@ function planetRows(chart: Record, vargaPacket: Record) { const ascendant = record(chart.ascendant); diff --git a/frontend/src/lib/chart-view-varga.ts b/frontend/src/lib/chart-view-varga.ts index 5265feff..4bf088ba 100644 --- a/frontend/src/lib/chart-view-varga.ts +++ b/frontend/src/lib/chart-view-varga.ts @@ -1,33 +1,35 @@ import tables from "./vedic-chart-tables.json"; -export type VargaDignity = "exalted" | "own" | "debilitated"; +/** + * The engine's dignity levels (`jyotish_engine._get_dignity_level`). A + * divisional row carries the level `/api/varga_full` computed with + * `varga_dignity_level`, the Vimsopaka function; the page never recomputes it. + */ +export const VARGA_DIGNITY_LEVELS = [ + "EXALTED", + "MOOLATRIKONA", + "OWN_SIGN", + "GREAT_FRIEND", + "FRIEND", + "NEUTRAL", + "ENEMY", + "GREAT_ENEMY", + "DEBILITATED", + "NEECHA_BHANGA", +] as const; + +export type VargaDignity = (typeof VARGA_DIGNITY_LEVELS)[number]; -const EXALTATION: Readonly> = tables.exaltation; -const DEBILITATION: Readonly> = tables.debilitation; -const SIGN_LORDS: Readonly> = tables.signLords; -const NODE_OWN_SIGN: Readonly> = tables.nodeOwnSign; const VARGA_MEANING: Readonly> = tables.vargaMeaning; -/** - * Sign dignity of a body inside a divisional chart. - * - * Same precedence as `jyotish_engine._get_dignity_level` (exalted, then own - * sign, then debilitated) without the parts that need a D1 degree or the whole - * chart: no moolatrikona band, no neecha bhanga, no friendship. The tables are - * the engine's; `tests/test_chart_page_vedic_tables_contract.py` pins them. - */ -export function vargaDignity(planet: string, sign: string): VargaDignity | null { - if (EXALTATION[planet] === sign) return "exalted"; - if (SIGN_LORDS[sign] === planet || NODE_OWN_SIGN[planet] === sign) return "own"; - if (DEBILITATION[planet] === sign) return "debilitated"; - return null; -} +/** Same wording as the D1 "状态" column (`jyotish_engine.DIGNITY_LABELS`). */ +export const VARGA_DIGNITY_LABEL: Readonly> = tables.dignityLabels; -export const VARGA_DIGNITY_LABEL: Record = { - exalted: "入旺", - own: "自宫", - debilitated: "落陷", -}; +export function vargaDignityLevel(value: unknown): VargaDignity | null { + return typeof value === "string" && (VARGA_DIGNITY_LEVELS as readonly string[]).includes(value) + ? value as VargaDignity + : null; +} /** The engine's own one-line use of a D-number chart (`VargaType.meaning`). */ export function vargaMeaning(division: number): string | null { diff --git a/frontend/src/lib/report-chart-block.ts b/frontend/src/lib/report-chart-block.ts index 5e54ef72..6a8e3bc5 100644 --- a/frontend/src/lib/report-chart-block.ts +++ b/frontend/src/lib/report-chart-block.ts @@ -203,6 +203,19 @@ export function toNorthIndianChart(block: ReportChartBlock, language: ReportLang }; } +/** + * Ids of the valid chart fences a report body draws. The reader page and the + * export both skip a structured chart whose id is already here (BUG-1203). + */ +export function fencedReportChartIds(markdown: string): Set { + const ids = new Set(); + for (const match of markdown.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) { + const block = parseReportChartBlock(match[1]); + if (block) ids.add(block.id); + } + return ids; +} + export function stripReportChartBlocks(markdown: string): string { const stripped = markdown.replace(FENCE_RE, "\n\n"); return stripped.replace(/\n{3,}/g, "\n\n"); diff --git a/frontend/src/lib/report-export-blocks.ts b/frontend/src/lib/report-export-blocks.ts index 777c9717..a29e59a2 100644 --- a/frontend/src/lib/report-export-blocks.ts +++ b/frontend/src/lib/report-export-blocks.ts @@ -1,7 +1,7 @@ import type { ReportDocumentV2 } from "./personal-report-contract"; import { reportChartExportSvg } from "./report-chart-export"; import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline"; -import { chartAriaLabel, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block"; +import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block"; import type { ReportFactTable } from "./report-fact-table-schema"; import { factCellText, factStatusLabel } from "./report-fact-table-display"; import type { ReportLanguage } from "./report-language"; @@ -71,12 +71,11 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = [] for (const section of outline.sections) { add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown)); } - const chartIds = new Set(); + const chartIds = fencedReportChartIds(projected); for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) { const block = parseReportChartBlock(match[1]); if (!block) continue; const occurrence = blocks.filter(item => item.group === "chart" && item.id.startsWith(`chart:${block.id}:`)).length + 1; - chartIds.add(block.id); // Only the immediately preceding SVG belongs to this occurrence (D9 may repeat). // The public projection has already rejected unsafe SVG; never recover raw HTML. const preceding = projected.slice(0, match.index); diff --git a/frontend/src/lib/vedic-chart-tables.json b/frontend/src/lib/vedic-chart-tables.json index 271f5113..ef143c75 100644 --- a/frontend/src/lib/vedic-chart-tables.json +++ b/frontend/src/lib/vedic-chart-tables.json @@ -1,5 +1,5 @@ { - "_comment": "Chart-page reference tables. Mirrors jyotish_engine.SIGN_LORDS / EXALTATION / DEBILITATION, the Rahu-Virgo / Ketu-Pisces own-sign rule in jyotish_engine._get_dignity_level, and divisional_charts_extended.VargaType meanings. tests/test_chart_page_vedic_tables_contract.py fails if either side drifts.", + "_comment": "Engine tables the chart page reads. vargaMeaning = divisional_charts_extended.VargaType meaning; dignityLabels = jyotish_engine.DIGNITY_LABELS (the D1 status wording). Pinned by tests/test_chart_page_vedic_tables_contract.py.", "vargaMeaning": { "1": "本命盘", "2": "财富", @@ -22,40 +22,16 @@ "45": "全面判断", "60": "前世业力" }, - "signLords": { - "Aries": "Mars", - "Taurus": "Venus", - "Gemini": "Mercury", - "Cancer": "Moon", - "Leo": "Sun", - "Virgo": "Mercury", - "Libra": "Venus", - "Scorpio": "Mars", - "Sagittarius": "Jupiter", - "Capricorn": "Saturn", - "Aquarius": "Saturn", - "Pisces": "Jupiter" - }, - "exaltation": { - "Sun": "Aries", - "Moon": "Taurus", - "Mars": "Capricorn", - "Mercury": "Virgo", - "Jupiter": "Cancer", - "Venus": "Pisces", - "Saturn": "Libra" - }, - "debilitation": { - "Sun": "Libra", - "Moon": "Scorpio", - "Mars": "Cancer", - "Mercury": "Pisces", - "Jupiter": "Capricorn", - "Venus": "Virgo", - "Saturn": "Aries" - }, - "nodeOwnSign": { - "Rahu": "Virgo", - "Ketu": "Pisces" + "dignityLabels": { + "EXALTED": "入旺(Exalted)", + "MOOLATRIKONA": "本垣(Moolatrikona)", + "OWN_SIGN": "入庙(Own Sign)", + "GREAT_FRIEND": "极友(Great Friend)", + "FRIEND": "入友(Friendly Sign)", + "NEUTRAL": "中性(Neutral)", + "ENEMY": "入敌(Enemy Sign)", + "GREAT_ENEMY": "极敌(Great Enemy)", + "DEBILITATED": "落陷(Debilitated)", + "NEECHA_BHANGA": "落陷取消(Neecha Bhanga)" } } \ No newline at end of file diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx index 0f8b1294..829ed4e4 100644 --- a/frontend/tests/chart-varga-table.test.tsx +++ b/frontend/tests/chart-varga-table.test.tsx @@ -90,13 +90,34 @@ test("a divisional chart's table carries that chart's signs, degrees and houses" assert.match(ascD9, /射手/, "D9 ascendant is Sagittarius"); }); -test("the D1 table is byte-for-byte the table the page drew before BUG-1076", async () => { +test("the D1 table is byte-for-byte the table the page drew before BUG-1076, ascendant nakshatra aside", async () => { const view = await vargaView(); const markup = render(view, "D1"); const table = markup.match(/
[\s\S]*?<\/table><\/div>/)?.[0]; - // Rendered from the baseline commit 5dc977ef with the same golden. + // Rendered from the baseline commit 5dc977ef with the same golden. The only + // change since is BUG-1201: the engine now gives the ascendant its nakshatra + // (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes. const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8"); - assert.equal(table, baseline); + const dashes = '————'; + const filled = 'Uttara Phalguni☉' + + '1—'; + assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row"); + assert.equal(table, baseline.replace(dashes, filled)); +}); + +// BUG-1201: the ascendant row's nakshatra cells were always dashes. +test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async () => { + const view = await vargaView(); + assert.equal(view.vedic.ascendant?.nakshatra, "Uttara Phalguni"); + assert.equal(view.vedic.ascendant?.pada, 1); + const ascendant = row(render(view, "D1"), "上升"); + assert.match(ascendant, /Uttara Phalguni<\/td>/); + assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun"); + // An older cached chart without the fields still renders, with dashes. + const chart = structuredClone(golden.chart); + for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record)[key]; + const old = buildChartView({ chart, varga: null, chara: null, western: null, profile, asOf: "2026-09-15" }); + assert.equal(old.vedic.ascendant?.nakshatra, null); }); test("divisional rows carry engine dignity and vargottama, and drop nakshatra columns", async () => { @@ -106,18 +127,23 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co const saturn = d9.rows.find((item) => item.name === "Saturn"); // D1 Saturn is in Capricorn and D9 Saturn is in Capricorn: own sign, vargottama. assert.equal(saturn?.sign, "Capricorn"); - assert.equal(saturn?.dignity, "own"); + assert.equal(saturn?.dignity, "OWN_SIGN"); assert.equal(saturn?.vargottama, true); const sun = d9.rows.find((item) => item.name === "Sun"); assert.equal(sun?.sign, "Libra"); - assert.equal(sun?.dignity, "debilitated"); + assert.equal(sun?.dignity, "DEBILITATED"); assert.equal(sun?.vargottama, false); const markup = render(view, "D9"); const header = markup.match(/([\s\S]*?)<\/thead>/)?.[1] ?? ""; for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`)); assert.doesNotMatch(header, /星宿|宿主|pada/); - assert.match(row(markup, "土星"), />自宫<[\s\S]*>同位落陷入庙\(Own Sign\)<[\s\S]*>同位落陷\(Debilitated\)入友\(Friendly Sign\)中性\(Neutral\)入敌\(Enemy Sign\)极敌\(Great Enemy\)不适用([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|>入旺<|>自宫<|>落陷([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)不适用 { + const varga = structuredClone(golden.varga_full); + for (const item of Object.values(varga.result)) { + for (const planet of Object.values(item.planets)) delete planet.dignity_level; + } + const view = buildChartView({ chart: structuredClone(golden.chart), varga: varga as unknown as Record, chara: null, western: null, profile, asOf: "2026-09-15" }); + const d9 = view.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(d9.supportsDignity, false); + assert.equal(d9.supportsVargottama, true); + assert.ok(d9.rows!.every((item) => item.dignity === null)); + assertColumns(tableMarkup(d9), false, true); + const unknown = structuredClone(golden.varga_full); + unknown.result.D9_Navamsa!.planets.Sun!.dignity_level = "SOMETHING_NEW"; + const mixed = buildChartView({ chart: structuredClone(golden.chart), varga: unknown as unknown as Record, chara: null, western: null, profile, asOf: "2026-09-15" }); + assert.equal(mixed.vedic.vargas.find((item) => item.id === "D9")!.supportsDignity, false); +}); + test("missing or invalid source positions are not manufactured as negative same-sign results", () => { const chart = structuredClone(golden.chart); const varga = structuredClone(golden.varga_full); diff --git a/frontend/tests/chart-view-varga-dignity.test.ts b/frontend/tests/chart-view-varga-dignity.test.ts index 24c5e217..a6f95bda 100644 --- a/frontend/tests/chart-view-varga-dignity.test.ts +++ b/frontend/tests/chart-view-varga-dignity.test.ts @@ -1,19 +1,24 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { vargaDignity, vargaMeaning } from "../src/lib/chart-view-varga.ts"; +import { VARGA_DIGNITY_LABEL, VARGA_DIGNITY_LEVELS, vargaDignityLevel, vargaMeaning } from "../src/lib/chart-view-varga.ts"; -// The tables are pinned to the engine by tests/test_chart_page_vedic_tables_contract.py; -// this pins the precedence (exalted, own, debilitated) the Python test mirrors. -test("sign dignity follows the engine's precedence and node own signs", () => { - assert.equal(vargaDignity("Sun", "Aries"), "exalted"); - assert.equal(vargaDignity("Sun", "Leo"), "own"); - assert.equal(vargaDignity("Sun", "Libra"), "debilitated"); - assert.equal(vargaDignity("Mercury", "Virgo"), "exalted", "exaltation wins over own sign"); - assert.equal(vargaDignity("Rahu", "Virgo"), "own"); - assert.equal(vargaDignity("Ketu", "Pisces"), "own"); - assert.equal(vargaDignity("Rahu", "Taurus"), null, "the engine has no node exaltation"); - assert.equal(vargaDignity("Moon", "Leo"), null); +// BUG-1202: the level is the engine's (`/api/varga_full` dignity_level); the +// page only validates it and labels it with the D1 status wording, pinned to +// jyotish_engine.DIGNITY_LABELS by tests/test_chart_page_vedic_tables_contract.py. +test("engine dignity levels are accepted as-is and anything else is unknown", () => { + for (const level of VARGA_DIGNITY_LEVELS) assert.equal(vargaDignityLevel(level), level); + assert.equal(vargaDignityLevel("exalted"), null, "the retired three-level values are not engine levels"); + assert.equal(vargaDignityLevel(undefined), null); + assert.equal(vargaDignityLevel(1), null); +}); + +test("every engine level has the D1 status wording", () => { + assert.equal(VARGA_DIGNITY_LABEL.EXALTED, "入旺(Exalted)"); + assert.equal(VARGA_DIGNITY_LABEL.OWN_SIGN, "入庙(Own Sign)"); + assert.equal(VARGA_DIGNITY_LABEL.ENEMY, "入敌(Enemy Sign)"); + assert.equal(VARGA_DIGNITY_LABEL.NEUTRAL, "中性(Neutral)"); + assert.deepEqual(Object.keys(VARGA_DIGNITY_LABEL).sort(), [...VARGA_DIGNITY_LEVELS].sort()); }); test("varga meanings come from the engine table including D40, D45 and D60", () => { diff --git a/frontend/tests/fixtures/chart-view-golden.json b/frontend/tests/fixtures/chart-view-golden.json index 3aceaacc..8d47e425 100644 --- a/frontend/tests/fixtures/chart-view-golden.json +++ b/frontend/tests/fixtures/chart-view-golden.json @@ -1,5 +1,5 @@ { - "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones).", + "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).", "chart": { "success": true, "birth": { @@ -26,7 +26,10 @@ "degree_in_sign": 29.3994, "degree_in_sign_raw": 29.399370365697422, "lon": 149.3994, - "lord": "Sun" + "lord": "Sun", + "nakshatra": "Uttara Phalguni", + "nakshatra_pada": 1, + "nakshatra_lord": "Sun" }, "planets": { "Sun": { @@ -324,63 +327,72 @@ "sign_index": 2, "degree": 1.5338, "house": 11, - "absolute_degree": 61.5338 + "absolute_degree": 61.5338, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Aquarius", "sign_index": 10, "degree": 18.6462, "house": 7, - "absolute_degree": 318.6462 + "absolute_degree": 318.6462, + "dignity_level": "FRIEND" }, "Mars": { "sign": "Pisces", "sign_index": 11, "degree": 18.5242, "house": 8, - "absolute_degree": 348.5242 + "absolute_degree": 348.5242, + "dignity_level": "GREAT_FRIEND" }, "Mercury": { "sign": "Taurus", "sign_index": 1, "degree": 12.8439, "house": 10, - "absolute_degree": 42.8439 + "absolute_degree": 42.8439, + "dignity_level": "GREAT_FRIEND" }, "Jupiter": { "sign": "Gemini", "sign_index": 2, "degree": 23.5398, "house": 11, - "absolute_degree": 83.5398 + "absolute_degree": 83.5398, + "dignity_level": "NEUTRAL" }, "Venus": { "sign": "Aries", "sign_index": 0, "degree": 26.1082, "house": 9, - "absolute_degree": 26.1082 + "absolute_degree": 26.1082, + "dignity_level": "FRIEND" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 1.774, "house": 6, - "absolute_degree": 271.774 + "absolute_degree": 271.774, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Capricorn", "sign_index": 9, "degree": 17.4381, "house": 6, - "absolute_degree": 287.4381 + "absolute_degree": 287.4381, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Cancer", "sign_index": 3, "degree": 17.4381, "house": 12, - "absolute_degree": 107.4381 + "absolute_degree": 107.4381, + "dignity_level": "GREAT_ENEMY" } }, "house_chart": [ @@ -430,63 +442,72 @@ "sign_index": 4, "degree": 3.0675, "house": 2, - "absolute_degree": 123.0675 + "absolute_degree": 123.0675, + "dignity_level": "MOOLATRIKONA" }, "Moon": { "sign": "Cancer", "sign_index": 3, "degree": 7.2923, "house": 1, - "absolute_degree": 97.2923 + "absolute_degree": 97.2923, + "dignity_level": "OWN_SIGN" }, "Mars": { "sign": "Leo", "sign_index": 4, "degree": 7.0484, "house": 2, - "absolute_degree": 127.0484 + "absolute_degree": 127.0484, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Cancer", "sign_index": 3, "degree": 25.6877, "house": 1, - "absolute_degree": 115.6877 + "absolute_degree": 115.6877, + "dignity_level": "GREAT_ENEMY" }, "Jupiter": { "sign": "Cancer", "sign_index": 3, "degree": 17.0797, "house": 1, - "absolute_degree": 107.0797 + "absolute_degree": 107.0797, + "dignity_level": "EXALTED" }, "Venus": { "sign": "Cancer", "sign_index": 3, "degree": 22.2165, "house": 1, - "absolute_degree": 112.2165 + "absolute_degree": 112.2165, + "dignity_level": "GREAT_ENEMY" }, "Saturn": { "sign": "Cancer", "sign_index": 3, "degree": 3.5481, "house": 1, - "absolute_degree": 93.5481 + "absolute_degree": 93.5481, + "dignity_level": "GREAT_ENEMY" }, "Rahu": { "sign": "Leo", "sign_index": 4, "degree": 4.8762, "house": 2, - "absolute_degree": 124.8762 + "absolute_degree": 124.8762, + "dignity_level": "GREAT_ENEMY" }, "Ketu": { "sign": "Leo", "sign_index": 4, "degree": 4.8762, "house": 2, - "absolute_degree": 124.8762 + "absolute_degree": 124.8762, + "dignity_level": "GREAT_ENEMY" } }, "house_chart": [ @@ -531,63 +552,72 @@ "sign_index": 2, "degree": 4.6013, "house": 3, - "absolute_degree": 64.6013 + "absolute_degree": 64.6013, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Gemini", "sign_index": 2, "degree": 25.9385, "house": 3, - "absolute_degree": 85.9385 + "absolute_degree": 85.9385, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Cancer", "sign_index": 3, "degree": 25.5725, "house": 4, - "absolute_degree": 115.5725 + "absolute_degree": 115.5725, + "dignity_level": "DEBILITATED" }, "Mercury": { "sign": "Virgo", "sign_index": 5, "degree": 8.5316, "house": 6, - "absolute_degree": 158.5316 + "absolute_degree": 158.5316, + "dignity_level": "EXALTED" }, "Jupiter": { "sign": "Aquarius", "sign_index": 10, "degree": 10.6195, "house": 11, - "absolute_degree": 310.6195 + "absolute_degree": 310.6195, + "dignity_level": "FRIEND" }, "Venus": { "sign": "Sagittarius", "sign_index": 8, "degree": 18.3247, "house": 9, - "absolute_degree": 258.3247 + "absolute_degree": 258.3247, + "dignity_level": "FRIEND" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 5.3221, "house": 10, - "absolute_degree": 275.3221 + "absolute_degree": 275.3221, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Taurus", "sign_index": 1, "degree": 22.3144, "house": 2, - "absolute_degree": 52.3144 + "absolute_degree": 52.3144, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Scorpio", "sign_index": 7, "degree": 22.3144, "house": 8, - "absolute_degree": 232.3144 + "absolute_degree": 232.3144, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -638,63 +668,72 @@ "sign_index": 2, "degree": 6.1351, "house": 2, - "absolute_degree": 66.1351 + "absolute_degree": 66.1351, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Leo", "sign_index": 4, "degree": 14.5847, "house": 4, - "absolute_degree": 134.5847 + "absolute_degree": 134.5847, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Virgo", "sign_index": 5, "degree": 14.0967, "house": 5, - "absolute_degree": 164.0967 + "absolute_degree": 164.0967, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Leo", "sign_index": 4, "degree": 21.3754, "house": 4, - "absolute_degree": 141.3754 + "absolute_degree": 141.3754, + "dignity_level": "GREAT_FRIEND" }, "Jupiter": { "sign": "Pisces", "sign_index": 11, "degree": 4.1593, "house": 11, - "absolute_degree": 334.1593 + "absolute_degree": 334.1593, + "dignity_level": "OWN_SIGN" }, "Venus": { "sign": "Capricorn", "sign_index": 9, "degree": 14.433, "house": 9, - "absolute_degree": 284.433 + "absolute_degree": 284.433, + "dignity_level": "NEUTRAL" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 7.0961, "house": 9, - "absolute_degree": 277.0961 + "absolute_degree": 277.0961, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Cancer", "sign_index": 3, "degree": 9.7525, "house": 3, - "absolute_degree": 99.7525 + "absolute_degree": 99.7525, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Capricorn", "sign_index": 9, "degree": 9.7525, "house": 9, - "absolute_degree": 279.7525 + "absolute_degree": 279.7525, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -743,63 +782,72 @@ "sign_index": 2, "degree": 7.6689, "house": 7, - "absolute_degree": 67.6689 + "absolute_degree": 67.6689, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Taurus", "sign_index": 1, "degree": 3.2309, "house": 6, - "absolute_degree": 33.2309 + "absolute_degree": 33.2309, + "dignity_level": "EXALTED" }, "Mars": { "sign": "Aquarius", "sign_index": 10, "degree": 2.6209, "house": 3, - "absolute_degree": 302.6209 + "absolute_degree": 302.6209, + "dignity_level": "ENEMY" }, "Mercury": { "sign": "Pisces", "sign_index": 11, "degree": 4.2193, "house": 4, - "absolute_degree": 334.2193 + "absolute_degree": 334.2193, + "dignity_level": "DEBILITATED" }, "Jupiter": { "sign": "Virgo", "sign_index": 5, "degree": 27.6992, "house": 10, - "absolute_degree": 177.6992 + "absolute_degree": 177.6992, + "dignity_level": "GREAT_ENEMY" }, "Venus": { "sign": "Leo", "sign_index": 4, "degree": 10.5412, "house": 9, - "absolute_degree": 130.5412 + "absolute_degree": 130.5412, + "dignity_level": "NEUTRAL" }, "Saturn": { "sign": "Virgo", "sign_index": 5, "degree": 8.8701, "house": 10, - "absolute_degree": 158.8701 + "absolute_degree": 158.8701, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Scorpio", "sign_index": 7, "degree": 27.1906, "house": 12, - "absolute_degree": 237.1906 + "absolute_degree": 237.1906, + "dignity_level": "FRIEND" }, "Ketu": { "sign": "Taurus", "sign_index": 1, "degree": 27.1906, "house": 6, - "absolute_degree": 57.1906 + "absolute_degree": 57.1906, + "dignity_level": "FRIEND" } }, "house_chart": [ @@ -849,63 +897,72 @@ "sign_index": 2, "degree": 9.2026, "house": 6, - "absolute_degree": 69.2026 + "absolute_degree": 69.2026, + "dignity_level": "ENEMY" }, "Moon": { "sign": "Taurus", "sign_index": 1, "degree": 21.877, "house": 5, - "absolute_degree": 51.877 + "absolute_degree": 51.877, + "dignity_level": "EXALTED" }, "Mars": { "sign": "Sagittarius", "sign_index": 8, "degree": 21.1451, "house": 12, - "absolute_degree": 261.1451 + "absolute_degree": 261.1451, + "dignity_level": "GREAT_FRIEND" }, "Mercury": { "sign": "Capricorn", "sign_index": 9, "degree": 17.0631, "house": 1, - "absolute_degree": 287.0631 + "absolute_degree": 287.0631, + "dignity_level": "ENEMY" }, "Jupiter": { "sign": "Libra", "sign_index": 6, "degree": 21.239, "house": 10, - "absolute_degree": 201.239 + "absolute_degree": 201.239, + "dignity_level": "NEUTRAL" }, "Venus": { "sign": "Virgo", "sign_index": 5, "degree": 6.6495, "house": 9, - "absolute_degree": 156.6495 + "absolute_degree": 156.6495, + "dignity_level": "DEBILITATED" }, "Saturn": { "sign": "Cancer", "sign_index": 3, "degree": 10.6442, "house": 7, - "absolute_degree": 100.6442 + "absolute_degree": 100.6442, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Libra", "sign_index": 6, "degree": 14.6287, "house": 10, - "absolute_degree": 194.6287 + "absolute_degree": 194.6287, + "dignity_level": "GREAT_FRIEND" }, "Ketu": { "sign": "Aries", "sign_index": 0, "degree": 14.6287, "house": 4, - "absolute_degree": 14.6287 + "absolute_degree": 14.6287, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -956,63 +1013,72 @@ "sign_index": 2, "degree": 10.7364, "house": 5, - "absolute_degree": 70.7364 + "absolute_degree": 70.7364, + "dignity_level": "ENEMY" }, "Moon": { "sign": "Gemini", "sign_index": 2, "degree": 10.5232, "house": 5, - "absolute_degree": 70.5232 + "absolute_degree": 70.5232, + "dignity_level": "NEUTRAL" }, "Mars": { "sign": "Capricorn", "sign_index": 9, "degree": 9.6693, "house": 12, - "absolute_degree": 279.6693 + "absolute_degree": 279.6693, + "dignity_level": "EXALTED" }, "Mercury": { "sign": "Capricorn", "sign_index": 9, "degree": 29.907, "house": 12, - "absolute_degree": 299.907 + "absolute_degree": 299.907, + "dignity_level": "ENEMY" }, "Jupiter": { "sign": "Scorpio", "sign_index": 7, "degree": 14.7788, "house": 10, - "absolute_degree": 224.7788 + "absolute_degree": 224.7788, + "dignity_level": "GREAT_FRIEND" }, "Venus": { "sign": "Libra", "sign_index": 6, "degree": 2.7577, "house": 9, - "absolute_degree": 182.7577 + "absolute_degree": 182.7577, + "dignity_level": "MOOLATRIKONA" }, "Saturn": { "sign": "Cancer", "sign_index": 3, "degree": 12.4182, "house": 6, - "absolute_degree": 102.4182 + "absolute_degree": 102.4182, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Scorpio", "sign_index": 7, "degree": 2.0668, "house": 10, - "absolute_degree": 212.0668 + "absolute_degree": 212.0668, + "dignity_level": "FRIEND" }, "Ketu": { "sign": "Taurus", "sign_index": 1, "degree": 2.0668, "house": 4, - "absolute_degree": 32.0668 + "absolute_degree": 32.0668, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -1061,63 +1127,72 @@ "sign_index": 2, "degree": 12.2702, "house": 4, - "absolute_degree": 72.2702 + "absolute_degree": 72.2702, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Gemini", "sign_index": 2, "degree": 29.1694, "house": 4, - "absolute_degree": 89.1694 + "absolute_degree": 89.1694, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Pisces", "sign_index": 11, "degree": 28.1934, "house": 1, - "absolute_degree": 358.1934 + "absolute_degree": 358.1934, + "dignity_level": "GREAT_FRIEND" }, "Mercury": { "sign": "Aries", "sign_index": 0, "degree": 12.7508, "house": 2, - "absolute_degree": 12.7508 + "absolute_degree": 12.7508, + "dignity_level": "FRIEND" }, "Jupiter": { "sign": "Sagittarius", "sign_index": 8, "degree": 8.3187, "house": 10, - "absolute_degree": 248.3187 + "absolute_degree": 248.3187, + "dignity_level": "MOOLATRIKONA" }, "Venus": { "sign": "Libra", "sign_index": 6, "degree": 28.866, "house": 8, - "absolute_degree": 208.866 + "absolute_degree": 208.866, + "dignity_level": "OWN_SIGN" }, "Saturn": { "sign": "Virgo", "sign_index": 5, "degree": 14.1922, "house": 7, - "absolute_degree": 164.1922 + "absolute_degree": 164.1922, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Capricorn", "sign_index": 9, "degree": 19.5049, "house": 11, - "absolute_degree": 289.5049 + "absolute_degree": 289.5049, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Cancer", "sign_index": 3, "degree": 19.5049, "house": 5, - "absolute_degree": 109.5049 + "absolute_degree": 109.5049, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -1168,63 +1243,72 @@ "sign_index": 6, "degree": 13.8039, "house": 11, - "absolute_degree": 193.8039 + "absolute_degree": 193.8039, + "dignity_level": "DEBILITATED" }, "Moon": { "sign": "Pisces", "sign_index": 11, "degree": 17.8156, "house": 4, - "absolute_degree": 347.8156 + "absolute_degree": 347.8156, + "dignity_level": "FRIEND" }, "Mars": { "sign": "Sagittarius", "sign_index": 8, "degree": 16.7176, "house": 1, - "absolute_degree": 256.7176 + "absolute_degree": 256.7176, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Aries", "sign_index": 0, "degree": 25.5947, "house": 5, - "absolute_degree": 25.5947 + "absolute_degree": 25.5947, + "dignity_level": "ENEMY" }, "Jupiter": { "sign": "Taurus", "sign_index": 1, "degree": 1.8585, "house": 6, - "absolute_degree": 31.8585 + "absolute_degree": 31.8585, + "dignity_level": "GREAT_ENEMY" }, "Venus": { "sign": "Scorpio", "sign_index": 7, "degree": 24.9742, "house": 12, - "absolute_degree": 234.9742 + "absolute_degree": 234.9742, + "dignity_level": "FRIEND" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 15.9663, "house": 2, - "absolute_degree": 285.9663 + "absolute_degree": 285.9663, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Gemini", "sign_index": 2, "degree": 6.9431, "house": 7, - "absolute_degree": 66.9431 + "absolute_degree": 66.9431, + "dignity_level": "FRIEND" }, "Ketu": { "sign": "Sagittarius", "sign_index": 8, "degree": 6.9431, "house": 1, - "absolute_degree": 246.9431 + "absolute_degree": 246.9431, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -1275,63 +1359,72 @@ "sign_index": 2, "degree": 15.3377, "house": 2, - "absolute_degree": 75.3377 + "absolute_degree": 75.3377, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Leo", "sign_index": 4, "degree": 6.4617, "house": 4, - "absolute_degree": 126.4617 + "absolute_degree": 126.4617, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Taurus", "sign_index": 1, "degree": 5.2418, "house": 1, - "absolute_degree": 35.2418 + "absolute_degree": 35.2418, + "dignity_level": "ENEMY" }, "Mercury": { "sign": "Taurus", "sign_index": 1, "degree": 8.4385, "house": 1, - "absolute_degree": 38.4385 + "absolute_degree": 38.4385, + "dignity_level": "NEUTRAL" }, "Jupiter": { "sign": "Capricorn", "sign_index": 9, "degree": 25.3983, "house": 9, - "absolute_degree": 295.3983 + "absolute_degree": 295.3983, + "dignity_level": "DEBILITATED" }, "Venus": { "sign": "Sagittarius", "sign_index": 8, "degree": 21.0825, "house": 8, - "absolute_degree": 261.0825 + "absolute_degree": 261.0825, + "dignity_level": "FRIEND" }, "Saturn": { "sign": "Virgo", "sign_index": 5, "degree": 17.7403, "house": 5, - "absolute_degree": 167.7403 + "absolute_degree": 167.7403, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Aquarius", "sign_index": 10, "degree": 24.3812, "house": 10, - "absolute_degree": 324.3812 + "absolute_degree": 324.3812, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Leo", "sign_index": 4, "degree": 24.3812, "house": 4, - "absolute_degree": 144.3812 + "absolute_degree": 144.3812, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -1381,63 +1474,72 @@ "sign_index": 2, "degree": 16.8715, "house": 1, - "absolute_degree": 76.8715 + "absolute_degree": 76.8715, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Leo", "sign_index": 4, "degree": 25.1079, "house": 3, - "absolute_degree": 145.1079 + "absolute_degree": 145.1079, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Taurus", "sign_index": 1, "degree": 23.766, "house": 12, - "absolute_degree": 53.766 + "absolute_degree": 53.766, + "dignity_level": "ENEMY" }, "Mercury": { "sign": "Taurus", "sign_index": 1, "degree": 21.2824, "house": 12, - "absolute_degree": 51.2824 + "absolute_degree": 51.2824, + "dignity_level": "NEUTRAL" }, "Jupiter": { "sign": "Aquarius", "sign_index": 10, "degree": 18.9382, "house": 9, - "absolute_degree": 318.9382 + "absolute_degree": 318.9382, + "dignity_level": "ENEMY" }, "Venus": { "sign": "Capricorn", "sign_index": 9, "degree": 17.1907, "house": 8, - "absolute_degree": 287.1907 + "absolute_degree": 287.1907, + "dignity_level": "NEUTRAL" }, "Saturn": { "sign": "Virgo", "sign_index": 5, "degree": 19.5143, "house": 4, - "absolute_degree": 169.5143 + "absolute_degree": 169.5143, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Pisces", "sign_index": 11, "degree": 11.8193, "house": 10, - "absolute_degree": 341.8193 + "absolute_degree": 341.8193, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Virgo", "sign_index": 5, "degree": 11.8193, "house": 4, - "absolute_degree": 161.8193 + "absolute_degree": 161.8193, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -1487,63 +1589,72 @@ "sign_index": 2, "degree": 18.4053, "house": 12, - "absolute_degree": 78.4053 + "absolute_degree": 78.4053, + "dignity_level": "ENEMY" }, "Moon": { "sign": "Virgo", "sign_index": 5, "degree": 13.7541, "house": 3, - "absolute_degree": 163.7541 + "absolute_degree": 163.7541, + "dignity_level": "GREAT_FRIEND" }, "Mars": { "sign": "Libra", "sign_index": 6, "degree": 12.2902, "house": 4, - "absolute_degree": 192.2902 + "absolute_degree": 192.2902, + "dignity_level": "ENEMY" }, "Mercury": { "sign": "Libra", "sign_index": 6, "degree": 4.1262, "house": 4, - "absolute_degree": 184.1262 + "absolute_degree": 184.1262, + "dignity_level": "NEUTRAL" }, "Jupiter": { "sign": "Pisces", "sign_index": 11, "degree": 12.478, "house": 9, - "absolute_degree": 342.478 + "absolute_degree": 342.478, + "dignity_level": "OWN_SIGN" }, "Venus": { "sign": "Aquarius", "sign_index": 10, "degree": 13.299, "house": 8, - "absolute_degree": 313.299 + "absolute_degree": 313.299, + "dignity_level": "GREAT_FRIEND" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 21.2884, "house": 7, - "absolute_degree": 291.2884 + "absolute_degree": 291.2884, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Cancer", "sign_index": 3, "degree": 29.2574, "house": 1, - "absolute_degree": 119.2574 + "absolute_degree": 119.2574, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Capricorn", "sign_index": 9, "degree": 29.2574, "house": 7, - "absolute_degree": 299.2574 + "absolute_degree": 299.2574, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -1593,63 +1704,72 @@ "sign_index": 10, "degree": 24.5404, "house": 12, - "absolute_degree": 324.5404 + "absolute_degree": 324.5404, + "dignity_level": "NEUTRAL" }, "Moon": { "sign": "Pisces", "sign_index": 11, "degree": 28.3388, "house": 1, - "absolute_degree": 358.3388 + "absolute_degree": 358.3388, + "dignity_level": "FRIEND" }, "Mars": { "sign": "Leo", "sign_index": 4, "degree": 26.3869, "house": 6, - "absolute_degree": 146.3869 + "absolute_degree": 146.3869, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Pisces", "sign_index": 11, "degree": 25.5016, "house": 1, - "absolute_degree": 355.5016 + "absolute_degree": 355.5016, + "dignity_level": "DEBILITATED" }, "Jupiter": { "sign": "Aquarius", "sign_index": 10, "degree": 16.6373, "house": 12, - "absolute_degree": 316.6373 + "absolute_degree": 316.6373, + "dignity_level": "FRIEND" }, "Venus": { "sign": "Taurus", "sign_index": 1, "degree": 27.7319, "house": 3, - "absolute_degree": 57.7319 + "absolute_degree": 57.7319, + "dignity_level": "OWN_SIGN" }, "Saturn": { "sign": "Capricorn", "sign_index": 9, "degree": 28.3845, "house": 11, - "absolute_degree": 298.3845 + "absolute_degree": 298.3845, + "dignity_level": "OWN_SIGN" }, "Rahu": { "sign": "Libra", "sign_index": 6, "degree": 9.0099, "house": 8, - "absolute_degree": 189.0099 + "absolute_degree": 189.0099, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Aries", "sign_index": 0, "degree": 9.0099, "house": 2, - "absolute_degree": 9.0099 + "absolute_degree": 9.0099, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -1699,63 +1819,72 @@ "sign_index": 7, "degree": 0.6754, "house": 1, - "absolute_degree": 210.6754 + "absolute_degree": 210.6754, + "dignity_level": "NEUTRAL" }, "Moon": { "sign": "Libra", "sign_index": 6, "degree": 12.9235, "house": 12, - "absolute_degree": 192.9235 + "absolute_degree": 192.9235, + "dignity_level": "FRIEND" }, "Mars": { "sign": "Cancer", "sign_index": 3, "degree": 10.4836, "house": 9, - "absolute_degree": 100.4836 + "absolute_degree": 100.4836, + "dignity_level": "DEBILITATED" }, "Mercury": { "sign": "Virgo", "sign_index": 5, "degree": 16.8771, "house": 11, - "absolute_degree": 166.8771 + "absolute_degree": 166.8771, + "dignity_level": "EXALTED" }, "Jupiter": { "sign": "Capricorn", "sign_index": 9, "degree": 20.7967, "house": 3, - "absolute_degree": 290.7967 + "absolute_degree": 290.7967, + "dignity_level": "DEBILITATED" }, "Venus": { "sign": "Virgo", "sign_index": 5, "degree": 12.1649, "house": 11, - "absolute_degree": 162.1649 + "absolute_degree": 162.1649, + "dignity_level": "NEECHA_BHANGA" }, "Saturn": { "sign": "Aquarius", "sign_index": 10, "degree": 5.4806, "house": 4, - "absolute_degree": 305.4806 + "absolute_degree": 305.4806, + "dignity_level": "MOOLATRIKONA" }, "Rahu": { "sign": "Sagittarius", "sign_index": 8, "degree": 18.7624, "house": 2, - "absolute_degree": 258.7624 + "absolute_degree": 258.7624, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Gemini", "sign_index": 2, "degree": 18.7624, "house": 8, - "absolute_degree": 78.7624 + "absolute_degree": 78.7624, + "dignity_level": "FRIEND" } }, "house_chart": [ @@ -1806,63 +1935,72 @@ "sign_index": 5, "degree": 6.8105, "house": 3, - "absolute_degree": 156.8105 + "absolute_degree": 156.8105, + "dignity_level": "ENEMY" }, "Moon": { "sign": "Libra", "sign_index": 6, "degree": 27.5082, "house": 4, - "absolute_degree": 207.5082 + "absolute_degree": 207.5082, + "dignity_level": "ENEMY" }, "Mars": { "sign": "Virgo", "sign_index": 5, "degree": 24.5803, "house": 3, - "absolute_degree": 174.5803 + "absolute_degree": 174.5803, + "dignity_level": "GREAT_ENEMY" }, "Mercury": { "sign": "Taurus", "sign_index": 1, "degree": 8.2525, "house": 11, - "absolute_degree": 38.2525 + "absolute_degree": 38.2525, + "dignity_level": "GREAT_FRIEND" }, "Jupiter": { "sign": "Aquarius", "sign_index": 10, "degree": 24.956, "house": 8, - "absolute_degree": 324.956 + "absolute_degree": 324.956, + "dignity_level": "ENEMY" }, "Venus": { "sign": "Aries", "sign_index": 0, "degree": 26.5979, "house": 10, - "absolute_degree": 26.5979 + "absolute_degree": 26.5979, + "dignity_level": "ENEMY" }, "Saturn": { "sign": "Leo", "sign_index": 4, "degree": 12.5767, "house": 2, - "absolute_degree": 132.5767 + "absolute_degree": 132.5767, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Leo", "sign_index": 4, "degree": 28.5148, "house": 2, - "absolute_degree": 148.5148 + "absolute_degree": 148.5148, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Leo", "sign_index": 4, "degree": 28.5148, "house": 2, - "absolute_degree": 148.5148 + "absolute_degree": 148.5148, + "dignity_level": "NEUTRAL" } }, "house_chart": [ @@ -1911,63 +2049,72 @@ "sign_index": 3, "degree": 11.4118, "house": 10, - "absolute_degree": 101.4118 + "absolute_degree": 101.4118, + "dignity_level": "GREAT_FRIEND" }, "Moon": { "sign": "Gemini", "sign_index": 2, "degree": 23.4467, "house": 9, - "absolute_degree": 83.4467 + "absolute_degree": 83.4467, + "dignity_level": "NEUTRAL" }, "Mars": { "sign": "Pisces", "sign_index": 11, "degree": 20.1529, "house": 6, - "absolute_degree": 350.1529 + "absolute_degree": 350.1529, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Sagittarius", "sign_index": 8, "degree": 16.784, "house": 3, - "absolute_degree": 256.784 + "absolute_degree": 256.784, + "dignity_level": "FRIEND" }, "Jupiter": { "sign": "Pisces", "sign_index": 11, "degree": 5.5755, "house": 6, - "absolute_degree": 335.5755 + "absolute_degree": 335.5755, + "dignity_level": "OWN_SIGN" }, "Venus": { "sign": "Pisces", "sign_index": 11, "degree": 14.9226, "house": 6, - "absolute_degree": 344.9226 + "absolute_degree": 344.9226, + "dignity_level": "EXALTED" }, "Saturn": { "sign": "Libra", "sign_index": 6, "degree": 17.8988, "house": 1, - "absolute_degree": 197.8988 + "absolute_degree": 197.8988, + "dignity_level": "EXALTED" }, "Rahu": { "sign": "Sagittarius", "sign_index": 8, "degree": 20.8292, "house": 3, - "absolute_degree": 260.8292 + "absolute_degree": 260.8292, + "dignity_level": "NEUTRAL" }, "Ketu": { "sign": "Gemini", "sign_index": 2, "degree": 20.8292, "house": 9, - "absolute_degree": 80.8292 + "absolute_degree": 80.8292, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -2015,63 +2162,72 @@ "sign_index": 0, "degree": 9.2026, "house": 12, - "absolute_degree": 9.2026 + "absolute_degree": 9.2026, + "dignity_level": "EXALTED" }, "Moon": { "sign": "Gemini", "sign_index": 2, "degree": 2.7695, "house": 2, - "absolute_degree": 62.7695 + "absolute_degree": 62.7695, + "dignity_level": "NEUTRAL" }, "Mars": { "sign": "Sagittarius", "sign_index": 8, "degree": 24.4657, "house": 8, - "absolute_degree": 264.4657 + "absolute_degree": 264.4657, + "dignity_level": "NEUTRAL" }, "Mercury": { "sign": "Sagittarius", "sign_index": 8, "degree": 3.1644, "house": 8, - "absolute_degree": 243.1644 + "absolute_degree": 243.1644, + "dignity_level": "ENEMY" }, "Jupiter": { "sign": "Gemini", "sign_index": 2, "degree": 23.7437, "house": 2, - "absolute_degree": 83.7437 + "absolute_degree": 83.7437, + "dignity_level": "GREAT_ENEMY" }, "Venus": { "sign": "Taurus", "sign_index": 1, "degree": 6.6495, "house": 1, - "absolute_degree": 36.6495 + "absolute_degree": 36.6495, + "dignity_level": "OWN_SIGN" }, "Saturn": { "sign": "Taurus", "sign_index": 1, "degree": 10.6442, "house": 1, - "absolute_degree": 40.6442 + "absolute_degree": 40.6442, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Sagittarius", "sign_index": 8, "degree": 20.3929, "house": 8, - "absolute_degree": 260.3929 + "absolute_degree": 260.3929, + "dignity_level": "GREAT_ENEMY" }, "Ketu": { "sign": "Sagittarius", "sign_index": 8, "degree": 20.3929, "house": 8, - "absolute_degree": 260.3929 + "absolute_degree": 260.3929, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -2118,63 +2274,72 @@ "sign_index": 8, "degree": 1.3509, "house": 6, - "absolute_degree": 241.3509 + "absolute_degree": 241.3509, + "dignity_level": "NEUTRAL" }, "Moon": { "sign": "Libra", "sign_index": 6, "degree": 25.847, "house": 4, - "absolute_degree": 205.847 + "absolute_degree": 205.847, + "dignity_level": "ENEMY" }, "Mars": { "sign": "Cancer", "sign_index": 3, "degree": 20.9672, "house": 1, - "absolute_degree": 110.9672 + "absolute_degree": 110.9672, + "dignity_level": "DEBILITATED" }, "Mercury": { "sign": "Gemini", "sign_index": 2, "degree": 3.7541, "house": 12, - "absolute_degree": 63.7541 + "absolute_degree": 63.7541, + "dignity_level": "OWN_SIGN" }, "Jupiter": { "sign": "Taurus", "sign_index": 1, "degree": 11.5933, "house": 11, - "absolute_degree": 41.5933 + "absolute_degree": 41.5933, + "dignity_level": "NEUTRAL" }, "Venus": { "sign": "Aquarius", "sign_index": 10, "degree": 24.3298, "house": 8, - "absolute_degree": 324.3298 + "absolute_degree": 324.3298, + "dignity_level": "GREAT_FRIEND" }, "Saturn": { "sign": "Pisces", "sign_index": 11, "degree": 10.9612, "house": 9, - "absolute_degree": 340.9612 + "absolute_degree": 340.9612, + "dignity_level": "FRIEND" }, "Rahu": { "sign": "Sagittarius", "sign_index": 8, "degree": 7.5247, "house": 6, - "absolute_degree": 247.5247 + "absolute_degree": 247.5247, + "dignity_level": "GREAT_ENEMY" }, "Ketu": { "sign": "Gemini", "sign_index": 2, "degree": 7.5247, "house": 12, - "absolute_degree": 67.5247 + "absolute_degree": 67.5247, + "dignity_level": "ENEMY" } }, "house_chart": [ @@ -2224,63 +2389,72 @@ "sign_index": 4, "degree": 9.0197, "house": 5, - "absolute_degree": 129.0197 + "absolute_degree": 129.0197, + "dignity_level": "MOOLATRIKONA" }, "Moon": { "sign": "Taurus", "sign_index": 1, "degree": 29.0779, "house": 2, - "absolute_degree": 59.0779 + "absolute_degree": 59.0779, + "dignity_level": "EXALTED" }, "Mars": { "sign": "Aquarius", "sign_index": 10, "degree": 23.5881, "house": 11, - "absolute_degree": 323.5881 + "absolute_degree": 323.5881, + "dignity_level": "FRIEND" }, "Mercury": { "sign": "Leo", "sign_index": 4, "degree": 7.9734, "house": 5, - "absolute_degree": 127.9734 + "absolute_degree": 127.9734, + "dignity_level": "NEUTRAL" }, "Jupiter": { "sign": "Taurus", "sign_index": 1, "degree": 9.2925, "house": 2, - "absolute_degree": 39.2925 + "absolute_degree": 39.2925, + "dignity_level": "NEUTRAL" }, "Venus": { "sign": "Cancer", "sign_index": 3, "degree": 4.8711, "house": 4, - "absolute_degree": 94.8711 + "absolute_degree": 94.8711, + "dignity_level": "NEUTRAL" }, "Saturn": { "sign": "Scorpio", "sign_index": 7, "degree": 19.8313, "house": 8, - "absolute_degree": 229.8313 + "absolute_degree": 229.8313, + "dignity_level": "NEUTRAL" }, "Rahu": { "sign": "Scorpio", "sign_index": 7, "degree": 4.7153, "house": 8, - "absolute_degree": 214.7153 + "absolute_degree": 214.7153, + "dignity_level": "FRIEND" }, "Ketu": { "sign": "Taurus", "sign_index": 1, "degree": 4.7153, "house": 2, - "absolute_degree": 34.7153 + "absolute_degree": 34.7153, + "dignity_level": "FRIEND" } }, "house_chart": [ @@ -2328,63 +2502,72 @@ "sign_index": 5, "degree": 2.0263, "house": 4, - "absolute_degree": 152.0263 + "absolute_degree": 152.0263, + "dignity_level": "FRIEND" }, "Moon": { "sign": "Pisces", "sign_index": 11, "degree": 8.7705, "house": 10, - "absolute_degree": 338.7705 + "absolute_degree": 338.7705, + "dignity_level": "FRIEND" }, "Mars": { "sign": "Aries", "sign_index": 0, "degree": 1.4508, "house": 11, - "absolute_degree": 1.4508 + "absolute_degree": 1.4508, + "dignity_level": "MOOLATRIKONA" }, "Mercury": { "sign": "Gemini", "sign_index": 2, "degree": 20.6312, "house": 1, - "absolute_degree": 80.6312 + "absolute_degree": 80.6312, + "dignity_level": "OWN_SIGN" }, "Jupiter": { "sign": "Taurus", "sign_index": 1, "degree": 2.39, "house": 12, - "absolute_degree": 32.39 + "absolute_degree": 32.39, + "dignity_level": "NEUTRAL" }, "Venus": { "sign": "Leo", "sign_index": 4, "degree": 6.4948, "house": 3, - "absolute_degree": 126.4948 + "absolute_degree": 126.4948, + "dignity_level": "NEUTRAL" }, "Saturn": { "sign": "Aries", "sign_index": 0, "degree": 16.4418, "house": 11, - "absolute_degree": 16.4418 + "absolute_degree": 16.4418, + "dignity_level": "DEBILITATED" }, "Rahu": { "sign": "Scorpio", "sign_index": 7, "degree": 26.2871, "house": 6, - "absolute_degree": 236.2871 + "absolute_degree": 236.2871, + "dignity_level": "ENEMY" }, "Ketu": { "sign": "Taurus", "sign_index": 1, "degree": 26.2871, "house": 12, - "absolute_degree": 56.2871 + "absolute_degree": 56.2871, + "dignity_level": "FRIEND" } }, "house_chart": [ diff --git a/frontend/tests/report-calculation-charts.test.tsx b/frontend/tests/report-calculation-charts.test.tsx new file mode 100644 index 00000000..4afe3fd4 --- /dev/null +++ b/frontend/tests/report-calculation-charts.test.tsx @@ -0,0 +1,48 @@ +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 { ReportCalculationCharts } from "../src/components/personal-report/report-calculation-charts.tsx"; +import { buildReportExportBlocks } from "../src/lib/report-export-blocks.ts"; +import { fencedReportChartIds } from "../src/lib/report-chart-block.ts"; +import { assembleLongformCharts } from "../src/lib/personal-report-generation.ts"; + +Object.assign(globalThis, { React }); + +// Real engine output and its real reader body (fictional chart). +const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8")); +const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")); +const charts = assembleLongformCharts(engine, "ev-cover-longform"); + +function render(markdown: string, language: "zh" | "en" = "zh"): string { + return renderToStaticMarkup(React.createElement(ReportCalculationCharts, { charts, markdown, language })); +} + +// BUG-1203: the body already drew these charts; the tail drew them again. +test("a report whose body draws every chart has no trailing chart section", () => { + const drawn = fencedReportChartIds(fixture.markdown); + assert.ok(charts.length > 0); + for (const chart of charts) assert.ok(drawn.has(chart.id), chart.id); + assert.equal(render(fixture.markdown), ""); + assert.equal(render(fixture.markdown, "en"), ""); +}); + +test("an older body without chart fences keeps its charts, without the moved caveat", () => { + const markup = render("# 报告\n\n正文"); + assert.match(markup, /

分盘核对<\/h2>/); + assert.equal((markup.match(/personal-report-chart-card/g) ?? []).length, charts.filter(chart => chart.houses.some(house => house.occupants.length)).length); + assert.doesNotMatch(markup, /出生时间敏感/); +}); + +test("the page and the export skip the same charts", () => { + const first = /```jyotish-chart\n[\s\S]*?```/.exec(fixture.markdown)![0]; + const drawn = [...fencedReportChartIds(first)]; + assert.equal(drawn.length, 1); + const markup = render(first); + const pageIds = charts.filter(chart => !drawn.includes(chart.id) && chart.houses.some(house => house.occupants.length)).map(chart => chart.id); + assert.equal((markup.match(/personal-report-chart-card/g) ?? []).length, pageIds.length); + const exported = buildReportExportBlocks({ markdown: first, charts }).filter(block => block.group === "chart" && !/^chart:[^:]+:\d+$/.test(block.id)); // fence charts carry an occurrence suffix + assert.deepEqual(exported.map(block => block.id.replace(/^chart:/, "")), pageIds); +}); diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index f16007ac..19114f56 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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: diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 4f74b7f7..5bc4afb8 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -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 直接用本命盘数据 diff --git a/scripts/pl9_reader_english_terms.py b/scripts/pl9_reader_english_terms.py index 7e4a04c5..c0e35c21 100644 --- a/scripts/pl9_reader_english_terms.py +++ b/scripts/pl9_reader_english_terms.py @@ -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 |", diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index c24641a4..26d66d0d 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -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')) diff --git a/scripts/varga_dignity.py b/scripts/varga_dignity.py new file mode 100644 index 00000000..428c35cb --- /dev/null +++ b/scripts/varga_dignity.py @@ -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 diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index a396f0a8..fbb84082 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -3413,6 +3413,30 @@ def test_varga_full_endpoint_returns_extended_standard_divisions() -> None: assert result['result']['D81_Navamsa-Navamsa']['planets']['Moon']['house'] in range(1, 13) +def test_varga_full_standard_divisions_carry_the_vimsopaka_dignity_level() -> None: + """BUG-1202: the chart page shows this level; it is the Vimsopaka function's.""" + import jyotish_engine + from varga import calc_all_vargas + + handler = _handler() + planets = _sample_planets() + result = handler._compute_varga_full({ + 'planets': planets, + 'ascendant': {'lon': 92.0}, + 'divisions': ['D9', 'D10'], + }) + d9 = result['result']['D9_Navamsa']['planets'] + assert {name: row['dignity_level'] for name, row in d9.items()} == { + name: jyotish_engine.varga_dignity_level(name, d9) for name in d9 + } + assert all(row['dignity_level'] in jyotish_engine.DIGNITY_LABELS for row in d9.values()) + # The Vimsopaka path reads the same positions from varga.calc_all_vargas. + vimsopaka_d9 = calc_all_vargas({name: row['lon'] for name, row in planets.items()}, 92.0, None)['D9_Navamsa'] + for name, row in d9.items(): + assert vimsopaka_d9[name]['sign'] == row['sign'], name + assert jyotish_engine.varga_dignity_level(name, vimsopaka_d9) == row['dignity_level'], name + + def test_varga_full_endpoint_supports_custom_and_composite_modes() -> None: handler = _handler() custom = handler._compute_varga_full({ diff --git a/tests/test_chart_page_vedic_tables_contract.py b/tests/test_chart_page_vedic_tables_contract.py index e49633c0..45cef283 100644 --- a/tests/test_chart_page_vedic_tables_contract.py +++ b/tests/test_chart_page_vedic_tables_contract.py @@ -1,9 +1,9 @@ """The chart page's dignity and varga-meaning tables must be the engine's. -`frontend/src/lib/vedic-chart-tables.json` is read by the chart-page mapper so -the divisional tables can show 入旺 / 自宫 / 落陷 without a second hand-written -rule set. This test pins it to the constants `jyotish_engine._get_dignity_level` -uses and to `divisional_charts_extended.VargaType`. +`frontend/src/lib/vedic-chart-tables.json` is read by the chart page: varga +meanings (`divisional_charts_extended.VargaType`) and the dignity wording +(`jyotish_engine.DIGNITY_LABELS`). The dignity level itself comes from +`/api/varga_full` (`varga_dignity_level`, BUG-1202); the page never derives it. """ from __future__ import annotations @@ -27,33 +27,11 @@ TABLES = json.loads((ROOT / "frontend/src/lib/vedic-chart-tables.json").read_tex PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] -def _table_dignity(planet: str, sign: str) -> str | None: - """Same precedence as `vargaDignity` in frontend/src/lib/chart-view-varga.ts.""" - if TABLES["exaltation"].get(planet) == sign: - return "EXALTED" - if TABLES["signLords"].get(sign) == planet or TABLES["nodeOwnSign"].get(planet) == sign: - return "OWN_SIGN" - if TABLES["debilitation"].get(planet) == sign: - return "DEBILITATED" - return None - - -def test_tables_equal_engine_constants() -> None: - assert TABLES["signLords"] == jyotish_engine.SIGN_LORDS - assert TABLES["exaltation"] == jyotish_engine.EXALTATION - assert TABLES["debilitation"] == jyotish_engine.DEBILITATION - - -def test_every_planet_sign_pair_matches_engine_dignity_level() -> None: - for planet in PLANETS: - for sign in jyotish_engine.SIGN_LORDS: - # No degree and no chart context: the varga table shows the sign - # dignity only (no moolatrikona band, no neecha bhanga, no friendship). - engine = jyotish_engine._get_dignity_level(planet, sign) - expected = engine if engine in {"EXALTED", "OWN_SIGN", "DEBILITATED"} else None - if engine == "NEECHA_BHANGA": - expected = "DEBILITATED" - assert _table_dignity(planet, sign) == expected, (planet, sign, engine) +def test_dignity_labels_equal_engine_status_wording() -> None: + # BUG-1202: divisional rows carry the engine's level and the page labels it + # with the D1 status wording; the three-level page table is retired. + assert TABLES["dignityLabels"] == jyotish_engine.DIGNITY_LABELS + assert {"signLords", "exaltation", "debilitation", "nodeOwnSign"}.isdisjoint(TABLES) def test_varga_meanings_equal_engine_varga_types() -> None: @@ -131,3 +109,17 @@ def test_d9_projected_ascendant_matches_native_summary(d9_projection: tuple[dict assert projected["ascendant"]["sign"] == native["lagna"] assert projected["ascendant"]["natalSign"] == golden["chart"]["ascendant"]["sign"] assert projected["ascendant"]["vargottama"] is native["lagna_vargottama"] + + +def test_d9_projected_dignity_is_the_engine_level(d9_projection: tuple[dict, dict]) -> None: + golden, projected = d9_projection + d9_planets = golden["varga_full"]["result"]["D9_Navamsa"]["planets"] + levels = set() + for row in projected["rows"]: + expected = jyotish_engine.varga_dignity_level(row["name"], d9_planets) + assert expected is not None + assert row["dignity"] == expected == d9_planets[row["name"]]["dignity_level"] + levels.add(expected) + # The golden must show that friendship levels reach the page, not only + # exalted / own / debilitated. + assert {"OWN_SIGN", "DEBILITATED", "NEUTRAL", "ENEMY", "FRIEND"} <= levels diff --git a/tests/test_report_chart_blank_columns.py b/tests/test_report_chart_blank_columns.py new file mode 100644 index 00000000..527d63b3 --- /dev/null +++ b/tests/test_report_chart_blank_columns.py @@ -0,0 +1,130 @@ +"""Blank report and chart columns (TASK-report-chart-blank-columns-20261003). + +BUG-1199 Kranti column with no producer; BUG-1200 Ashtakavarga house table +labelled the with-Lagna total as SAV (split in the reader: ashtakavarga.py is +in the frozen rectification identity and stays byte-identical); BUG-1201 ascendant without nakshatra; +BUG-1203 the divisional-chart caveat moved to the chart section. Every chart +here is fictional. +""" + +from __future__ import annotations + +import copy +import json +import re +from pathlib import Path + +import pytest + +from scripts.ashtakavarga import BAV_TOTALS, EXPECTED_SAV_TOTAL, SIGNS +from scripts.jyotish_engine import NAKSHATRA_LIST, _nakshatra_fields, compute_chart_data +from scripts.pl9_reader_export import _pl9_export_markdown_for_edition +from tests.test_report_english_edition import CASES, _packet + +ROOT = Path(__file__).resolve().parents[1] +CAVEAT_ZH = "分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。" + + +def _independent_nakshatra(lon: float) -> tuple[str, int, str]: + minutes = (lon % 360) * 60 + index = int(minutes // 800) # 13°20′ = 800′ + pada = int((minutes % 800) // 200) + 1 # 3°20′ = 200′ + name, lord, _ = NAKSHATRA_LIST[index] + return name, pada, lord + + +@pytest.mark.parametrize("lon,expected", [ + (0.0, ("Ashwini", 1, "Ketu")), + (13.33, ("Ashwini", 4, "Ketu")), + (13.34, ("Bharani", 1, "Venus")), + (134.53, ("Purva Phalguni", 1, "Venus")), + (149.3994, ("Uttara Phalguni", 1, "Sun")), + (359.99, ("Revati", 4, "Mercury")), + (360.0, ("Ashwini", 1, "Ketu")), +]) +def test_nakshatra_fields_at_boundaries(lon, expected) -> None: + fields = _nakshatra_fields(lon) + assert (fields["nakshatra"], fields["nakshatra_pada"], fields["nakshatra_lord"]) == expected + + +def test_chart_ascendant_carries_its_nakshatra_and_planets_are_unchanged() -> None: + golden = json.loads((ROOT / "frontend/tests/fixtures/chart-view-golden.json").read_text(encoding="utf-8")) + chart, *_ = compute_chart_data(1990, 6, 15, 12, 0, 39.9042, 116.4074, 8.0, ayanamsa_name="raman") + asc = chart["ascendant"] + assert (asc["nakshatra"], asc["nakshatra_pada"], asc["nakshatra_lord"]) == _independent_nakshatra(asc["lon"]) + assert {k: asc[k] for k in ("nakshatra", "nakshatra_pada", "nakshatra_lord")} == { + k: golden["chart"]["ascendant"][k] for k in ("nakshatra", "nakshatra_pada", "nakshatra_lord") + } + for name, planet in chart["planets"].items(): + stored = golden["chart"]["planets"][name] + assert (planet["nakshatra"], planet["nakshatra_pada"], planet["nakshatra_lord"]) == ( + stored["nakshatra"], stored["nakshatra_pada"], stored["nakshatra_lord"]), name + + +@pytest.fixture(scope="module") +def editions() -> tuple[str, str, dict]: + packet = _packet(CASES["day"]) + zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main") + english = copy.deepcopy(packet) + english["report_language"] = "en" + return zh, _pl9_export_markdown_for_edition(english, "reader_main"), packet + + +def _table_after(markdown: str, heading: str) -> list[list[str]]: + lines = markdown.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("### ") and heading in line) + rows = [] + for line in lines[start + 1:]: + if line.startswith("### ") or (rows and not line.startswith("|")): + break + if line.startswith("|") and not set(line) <= set("|- "): + rows.append([cell.strip() for cell in line.strip("|").split("|")]) + return rows + + +def test_declination_table_has_no_kranti_column(editions) -> None: + for markdown, heading in ((editions[0], "Declination / Speed"), (editions[1], "Declination / Speed")): + assert "Kranti" not in markdown + rows = _table_after(markdown, heading) + assert rows[0] == ["Planet", "Degree", "Declination", "Speed"] + assert len(rows) == 8 and all(len(row) == 4 and "-" not in row for row in rows) + + +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"] + 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"), + ): + 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:]) + assert sav + lagna == full + assert totals == [total, "-", 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]] + + +def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> None: + packet = copy.deepcopy(editions[2]) + del packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["bav"]["Lagna"] + 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() + + +def test_chart_section_carries_the_divisional_caveat(editions) -> None: + zh, en, _ = editions + assert re.search(r"### 本命与分盘北印度图盘\n\n" + re.escape(CAVEAT_ZH) + r"\n", zh) + assert re.search(r"### Natal and Divisional Charts \(North Indian\)\n\nDivisional charts are sensitive to birth time\.", en)