Merge branch 'codex/163-engine-columns' into codex/integrate-163
Independent Staging Quality Gate / validate (push) Successful in 18m55s
Independent Staging Quality Gate / publish (push) Successful in 4m39s

# Conflicts:
#	CHANGELOG.md
#	frontend/src/components/chart-board/chart-data-card.tsx
This commit is contained in:
2026-11-02 20:38:18 +08:00
25 changed files with 3529 additions and 145 deletions
+11
View File
@@ -11,6 +11,17 @@
- 名称:「八分点」改叫「八分法」(提示里写全称「八分法(Ashtakavarga)」),分数仍写「N 点」,表里分出单星表 BAV 和总表 SAV;「映射」改叫「Arudha 映射」。
- 占星师 2026-11-02 在 issue #163 的答复第 4–8 条。Skill 版本不变。
## 2026-11-02 — 盘面数据:功能吉凶列、Chara Karaka 七 / 八星切换、特殊点三个新组
- 「行星」表新增四列:自然属性、管辖宫位、功能角色、补充身份。功能角色来自引擎的 `bphs_ch34_general_with_sign_exceptions_v2`(BPHS 第 34 章译本 + 各上升明文例外),写「偏吉/支持」「偏凶/挑战」「混合/待综合」;罗睺、计都写「待综合」,不写「中性」。补充身份单列 Yogakaraka(严格定义:分别主管 4/7/10 之一和 5/9 之一)、Badhaka(动/固/双上升取 11/9/7 宫主)和「Kendradhipati 待综合」,都不改变功能角色。自然属性和功能角色方向相反时,功能角色带虚下划线,悬停说明。出处写在列头的悬停提示里。
- 「指标」列改名「Chara Karaka」,单元格写「AK · 自我」「AmK · 职事」等,梵文全名和含义在悬停提示里。表头多了「七星制 / 八星制」切换,默认七星;八星制含罗睺(按 30° 减星座内度数排)、不含计都。按未取整的星座内度数排序;只有原始精度下仍同度的几颗星写「并列/角色待定」,其余角色照常显示,不再整列留空(BUG-1512)。Goel 的星座力用同一套排序。
- 「特殊点」分四组:特殊上升 / 敏感点 / Saham / Goel 特殊点,盘上的「特殊点」图层跟着当前组走。
- 敏感点:Bhrigu 点(罗睺到月亮顺行弧中点,跟随本盘的交点口径)、第 64 Navamsa(从月亮)、第 22 Drekkana(从上升),每项写明参照;后两项只有星座和星座主,不补度数。
- Saham:先列 Punya、Vidya、Yashas、Mitra、Vivaha、Artha、Labha、Karyasiddhi、Paradesa 九项,其余已算的放在「全部」里(共 56 项,其中 20 项旧式候选标明未核对来源)。公式按 PVR《Vedic Astrology: An Integrated Approach》§28.8.1 表 74,逐项处理昼夜、不反转的例外和 +30°;昼夜按出生时地。公式列写出当次用的公式。
- Goel 特殊点:Brahma / Maheshwara / Rudra 按「书面规则含交点」取星,旁边一列写「示例排除交点」的结果,两种口径取到不同的星时标出来。Karaka 仍按七星制。
- 接口 `/api/chart_points` 新增 `karaka_scheme`(`7` 默认、`8`、`both`)和 `functional_roles`、`sensitive_points`、`sahams`、`goel_points` 四块;每块单独失败,不影响其它。
- 产品 2026-11-02 按占星师答复定(issue #163 第 1—3 项)。Skill 版本不变。
## 2026-11-02 — 对话输入框左边加「常用问题」
- 首页对话输入框左下角多了一个圆形按钮「常用问题」。点开先选分类(认识自己、感情与婚姻、事业与工作、钱与财富、家庭、父母、孩子、学习与考试、压力与生活节奏、居住与迁移、未来十二个月、时机),再选一个问题,问题全文会填进输入框,不会直接发出去。
+15
View File
@@ -20742,3 +20742,18 @@
- 相关记录:BUG-1470(顶栏高度与两行问题);同一变更是 2026-11-02 顶栏控件统一 44px 与选择星盘改版。
- 复发自:无
- 修复版本:提交 1cd73bc8(分支 `codex/chart-polish-1102`),2026-11-02 推上 staging。
## BUG-1512 | 盘面数据 Chara Karaka 列:两颗星四位小数相同就整列留空,同度时也没有明确的并列信息
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-11-02 / 2026-11-02(issue #163 占星师答复 2)
- 影响面:`scripts/chart_view_points/api.py` 的 `chara_karakas` 块;星盘页「盘面数据 · 行星」的 Karaka 列;`scripts/goel_jaimini_strength.py` 的同度判断。
- 用户现象:七颗星里有两颗的星座内度数取到四位小数相同,Karaka 列七格全部是「—」,没有角色、也看不出是哪两颗星。
- 触发条件:两颗星星座内度数四舍五入到 0.0001° 后相等(不必真的同度)。
- 根因:接口先把度数取四位小数再比较,相等就整块返回 `blocked: tied_degrees`;前端拿不到任何一行就整列留空。`jaimini.calc_chara_karaka_7 / 8` 自身遇到真正同度时按输入顺序排,不报并列;Goel 另写了一套 1e-10 的两两比较,两条路径口径不同。
- 修复:新增 `scripts/chart_view_points/karaka_rank.py::rank_chara_karakas`(七星 / 八星,按未取整度数排,1e-10 内相等才算并列),并列的几颗星跨的角色标 `tied`、列出候选,其余角色保留;接口和 Goel 都用它,Goel 遇到并列仍整体 blocked 并带上 `karaka_ties`。前端并列格写「并列/角色待定」。`jaimini.py` 在生时校正冻结身份里,没有改,非并列输入下两者排序一致。
- 验证:`tests/test_chara_karaka_rank.py`(与 `calc_chara_karaka_7 / 8` 20 组随机输入排序一致、三星并列、输入顺序无关、罗睺反向度数并列);`tests/test_chart_view_points_api.py::test_chara_karakas_mark_only_the_tied_roles`(原断言整块 blocked,按三栏改)与 `test_chara_karakas_sort_by_unrounded_degree`;`frontend/tests/chart-data-engine-columns-20261102.test.tsx`「a karaka tie marks only the tied bodies」。
- 防复发:上面的测试。规则:排序按原始精度;并列只标受影响的角色,不能清空整列,也不能按数组顺序硬分。
- 相关记录:BUG-1194、BUG-1204(8 星制排序与次序)。
- 复发自:无
- 修复版本:分支 `codex/163-engine-columns`(基于 `origin/staging` 4dbaf518),待合并。
@@ -4,3 +4,4 @@
* of `/` and never reaches the Node test runner (which cannot load .css).
*/
import "./chart-board.css";
import "./chart-data-engine.css";
@@ -18,8 +18,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Table } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
import { ChartSymbolHelp } from "@/components/chart-page/chart-symbol-help";
import { AscendantStatusCell } from "@/components/chart-page/chart-vedic-tab";
import type { ChartBoardState } from "@/hooks/use-chart-board";
import {
ASHTAKAVARGA_TITLE,
@@ -38,7 +36,6 @@ import {
type DashaSystem,
type RelationMode,
} from "@/lib/chart-board-focus";
import { dignityStep } from "@/lib/chart-board-model";
import { natalBoard, pointsOf, progressOf, SAV_REFERENCE, shadbalaFor, SHADBALA_KEYS, type PointGroup } from "@/lib/chart-board-view";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
@@ -49,6 +46,8 @@ import { vimshottariSplit, type DashaSpan } from "@/lib/dasha-span";
import { dragScroll } from "@/lib/drag-scroll";
import { PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs";
import { PlanetTable } from "./chart-data-planets";
import { SpecialPointsPanel } from "./chart-data-special";
import { ChartTypeTableBody } from "./chart-type-table";
const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const;
@@ -71,75 +70,6 @@ function Waiting({ pending, failure }: { pending: boolean; failure?: ChartViewFa
: <p className="chart-page-note" aria-busy={pending}></p>;
}
function PlanetTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
const karakas = view.points?.status === "ok" ? view.points.karakas ?? null : null;
const karakaOf = (name: string) => karakas?.find((row) => row.planet === name)?.key ?? "—";
const current = board.selection?.kind === "body" ? board.selection.key : null;
const asc = view.vedic.ascendant;
const ascOn = board.selection?.kind === "house" && board.model !== null && board.selection.house === board.model.ascendantHouse;
const pickAsc = () => board.model && board.select(ascOn ? null : { kind: "house", house: board.model.ascendantHouse });
// ChartSymbolHelp shows the ascendant cell's help bubble on hover, focus or tap.
return (
<ChartSymbolHelp resetKey="data-planets">
<Wide>
<Table className="chart-graha-table is-sticky">
<thead>
<tr>
<th scope="col">星体</th>
<th scope="col">位置</th>
<th scope="col">宫</th>
<th scope="col">月宿 · 足</th>
<th scope="col">宿主</th>
<th scope="col">旺陷</th>
<th scope="col">指标</th>
</tr>
</thead>
<tbody>
{asc ? (
<tr className={ascOn ? "is-selected" : undefined} aria-selected={ascOn} onClick={pickAsc}>
<th scope="row">{asc.label}</th>
<td className="chart-graha-num">{asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}</td>
<td className="chart-graha-num">1</td>
<td>{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · ${asc.pada}` : ""}` : "—"}</td>
<td>{asc.nakshatraLord ?? "—"}</td>
<AscendantStatusCell />
<td>—</td>
</tr>
) : null}
{view.vedic.planets.map((row) => {
const key = planetGlyphKey(row.name);
const on = key !== null && current === key;
const dignity = dignityStep(row.status);
const node = key === "rahu" || key === "ketu";
return (
<tr
key={row.name}
className={on ? "is-selected" : undefined}
aria-selected={on}
onClick={() => key && board.select(on ? null : { kind: "body", key })}
>
<th scope="row">{row.label}</th>
<td className="chart-graha-num">
{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}
{row.retrograde && !node ? <span className="chart-graha-retro"> 逆</span> : null}
</td>
<td className="chart-graha-num">{row.house}</td>
<td>{row.nakshatra} · {row.pada}</td>
<td>{row.nakshatraLord}</td>
<td className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>
{dignity?.label ?? "—"}
</td>
<td className="chart-graha-karaka">{node ? "—" : karakaOf(row.name)}</td>
</tr>
);
})}
</tbody>
</Table>
</Wide>
</ChartSymbolHelp>
);
}
function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) {
const rows = pointsOf(view, group);
if (!rows) return <p className="chart-page-note">这张表暂时没算出来。</p>;
@@ -632,7 +562,9 @@ export function ChartDataCard({
let body: ReactNode;
if (tab === "planets") {
body = type === "D1" ? <PlanetTable view={view} board={board} /> : <ChartTypeTableBody view={view} type={type} />;
} else if (tab === "upagraha" || tab === "special" || tab === "arudha") {
} else if (tab === "special") {
body = pointsReady ? <SpecialPointsPanel view={view} board={board} /> : <Waiting pending={pointsPending} failure={layerFailures?.get("points")} />;
} else if (tab === "upagraha" || tab === "arudha") {
body = pointsReady ? <PointTable view={view} board={board} group={tab} /> : <Waiting pending={pointsPending} failure={layerFailures?.get("points")} />;
} else if (tab === "av") {
body = avReady ? <AshtakavargaTable view={view} /> : <Waiting pending={pendingLayers?.has("ashtakavarga") ?? false} failure={layerFailures?.get("ashtakavarga")} />;
@@ -0,0 +1,29 @@
/*
* 盘面数据 engine columns (issue 163): Chara karaka ties, functional roles and
* their identity labels, and the 特殊点 groups. Theme tokens only; muted, no
* per-role colour. Cells stay on one line like the rest of the card.
*/
.chart-data-card .chart-identity-list { display: inline-flex; gap: 4px; white-space: nowrap; }
.chart-data-card .chart-identity {
display: inline-block;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 9px;
background: color-mix(in srgb, var(--color-canvas-soft) 80%, transparent);
color: var(--color-ink-secondary);
font-size: 11px;
line-height: 18px;
}
.chart-data-card .chart-identity.is-pending { border-style: dashed; color: var(--color-ink-tertiary); background: transparent; }
.chart-data-card .chart-functional-role.is-pending,
.chart-data-card .chart-graha-table td.is-pending,
.chart-data-card .chart-point-legacy { color: var(--color-ink-tertiary); }
.chart-data-card .chart-functional-role.is-conflict {
text-decoration: underline dotted color-mix(in srgb, var(--color-warning) 55%, transparent);
text-underline-offset: 3px;
}
.chart-data-card .chart-graha-karaka.is-tied { color: var(--color-ink-tertiary); font-weight: 400; }
.chart-data-card .chart-graha-table td.is-differs {
color: var(--color-ink-strong);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
}
@@ -0,0 +1,183 @@
"use client";
/**
* 盘面数据 · 行星 (D1): position, nakshatra, dignity, the Chara karaka role and
* the functional columns (issue #163). Karaka roles and functional roles are
* the engine's; a karaka role that bodies equal at full precision span reads
* 「并列/角色待定」 and the other roles stay. Natural and functional nature
* are separate columns; Yogakaraka, Badhaka and Kendradhipati sit beside the
* functional role and never replace it.
*/
import { useState, type ReactNode } from "react";
import { Table } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
import { ChartSymbolHelp } from "@/components/chart-page/chart-symbol-help";
import { AscendantStatusCell } from "@/components/chart-page/chart-vedic-tab";
import type { ChartBoardState } from "@/hooks/use-chart-board";
import { dignityStep } from "@/lib/chart-board-model";
import {
FUNCTIONAL_BASIS_TITLE,
FUNCTIONAL_CONFLICT_TITLE,
FUNCTIONAL_ZH,
IDENTITY_HEAD_TITLE,
IDENTITY_TITLE,
identityLabel,
KARAKA_SCHEME_NOTE,
KARAKA_TIED,
karakaCell,
karakaTitle,
NATURAL_CONDITION_TITLE,
NATURAL_ZH,
} from "@/lib/chart-data-terms";
import type { ChartViewFunctional, ChartViewKarakaTable, ChartViewOk } from "@/lib/chart-view-contract";
import { planetZh } from "@/lib/chart-view-labels";
import { dragScroll } from "@/lib/drag-scroll";
import { planetGlyphKey } from "@/lib/planet-glyphs";
type Scheme = "7" | "8";
function Wide({ children }: { children: ReactNode }) {
return <div className="chart-graha-table-wrap chart-drag" onPointerDown={dragScroll}>{children}</div>;
}
function KarakaCell({ table, name }: { table: ChartViewKarakaTable | null; name: string }) {
if (!table) return <td>—</td>;
const own = table.rows.find((row) => row.planet === name);
if (own) return <td className="chart-graha-karaka" title={karakaTitle(own.key)}>{karakaCell(own.key)}</td>;
const contested = table.rows.filter((row) => row.planet === null && row.candidates.includes(name));
if (contested.length === 0) return <td>—</td>;
const others = contested[0]!.candidates.filter((item) => item !== name).map(planetZh).join("、");
return (
<td className="chart-graha-karaka is-tied" title={`与${others}同度,${contested.map((row) => row.key).join(" / ")} 待定`}>
{KARAKA_TIED}
</td>
);
}
function FunctionalCells({ row }: { row: ChartViewFunctional["rows"][number] | undefined }) {
if (!row) return <><td>—</td><td>—</td><td>—</td><td>—</td></>;
const natural = row.naturalCondition ? NATURAL_ZH[row.naturalCondition] : NATURAL_ZH[row.natural];
const roleTitle = `${FUNCTIONAL_BASIS_TITLE[row.basis]}${row.conflict ? `;${FUNCTIONAL_CONFLICT_TITLE}` : ""}`;
const roleClass = [
"chart-functional-role",
row.role === "pending" || row.role === "neutral" ? "is-pending" : null,
row.conflict ? "is-conflict" : null,
].filter(Boolean).join(" ");
return (
<>
<td title={row.naturalCondition ? NATURAL_CONDITION_TITLE[row.naturalCondition] : undefined}>{natural}</td>
<td className="chart-graha-num">{row.houses.length > 0 ? row.houses.join("、") : "—"}</td>
<td className={roleClass} title={roleTitle}>{FUNCTIONAL_ZH[row.role]}</td>
<td>
{row.identities.length > 0 ? (
<span className="chart-identity-list">
{row.identities.map((item) => (
<span
key={item.key}
className={item.key === "kendradhipati" ? "chart-identity is-pending" : "chart-identity"}
title={IDENTITY_TITLE[item.key]}
>
{identityLabel(item.key, item.house)}
</span>
))}
</span>
) : "—"}
</td>
</>
);
}
export function PlanetTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
const [scheme, setScheme] = useState<Scheme>("7");
const points = view.points?.status === "ok" ? view.points : null;
const karakas = points?.karakaTables?.[scheme] ?? null;
const eightReady = Boolean(points?.karakaTables?.["8"]);
const functional = points?.functional ?? null;
const functionalOf = (name: string) => functional?.rows.find((row) => row.name === name);
const current = board.selection?.kind === "body" ? board.selection.key : null;
const asc = view.vedic.ascendant;
const ascOn = board.selection?.kind === "house" && board.model !== null && board.selection.house === board.model.ascendantHouse;
const pickAsc = () => board.model && board.select(ascOn ? null : { kind: "house", house: board.model.ascendantHouse });
// ChartSymbolHelp shows the ascendant cell's help bubble on hover, focus or tap.
return (
<>
<div className="chart-board-toolbar">
<Tabs value={scheme} onValueChange={(value) => value && setScheme(value as Scheme)}>
<TabsList variant="default" aria-label="Chara Karaka 口径">
<TabsTrigger value="7" className="chart-board-tab" title={KARAKA_SCHEME_NOTE["7"]}>七星制</TabsTrigger>
<TabsTrigger value="8" className="chart-board-tab" title={KARAKA_SCHEME_NOTE["8"]} disabled={!eightReady}>八星制</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ChartSymbolHelp resetKey="data-planets">
<Wide>
<Table className="chart-graha-table is-sticky">
<thead>
<tr>
<th scope="col">星体</th>
<th scope="col">位置</th>
<th scope="col">宫</th>
<th scope="col">月宿 · 足</th>
<th scope="col">宿主</th>
<th scope="col">旺陷</th>
<th scope="col" title={KARAKA_SCHEME_NOTE[scheme]}>Chara Karaka</th>
<th scope="col">自然属性</th>
<th scope="col">管辖宫位</th>
<th scope="col" title={functional ? `${functional.profile} · ${functional.translation}` : undefined}>功能角色</th>
<th scope="col" title={IDENTITY_HEAD_TITLE}>补充身份</th>
</tr>
</thead>
<tbody>
{asc ? (
<tr className={ascOn ? "is-selected" : undefined} aria-selected={ascOn} onClick={pickAsc}>
<th scope="row">{asc.label}</th>
<td className="chart-graha-num">{asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}</td>
<td className="chart-graha-num">1</td>
<td>{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · ${asc.pada}` : ""}` : "—"}</td>
<td>{asc.nakshatraLord ?? "—"}</td>
<AscendantStatusCell />
<td>—</td>
<td>—</td>
<td>—</td>
<td>—</td>
<td>—</td>
</tr>
) : null}
{view.vedic.planets.map((row) => {
const key = planetGlyphKey(row.name);
const on = key !== null && current === key;
const dignity = dignityStep(row.status);
const node = key === "rahu" || key === "ketu";
return (
<tr
key={row.name}
className={on ? "is-selected" : undefined}
aria-selected={on}
onClick={() => key && board.select(on ? null : { kind: "body", key })}
>
<th scope="row">{row.label}</th>
<td className="chart-graha-num">
{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}
{row.retrograde && !node ? <span className="chart-graha-retro"> 逆</span> : null}
</td>
<td className="chart-graha-num">{row.house}</td>
<td>{row.nakshatra} · {row.pada}</td>
<td>{row.nakshatraLord}</td>
<td className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>
{dignity?.label ?? "—"}
</td>
<KarakaCell table={karakas} name={row.name} />
<FunctionalCells row={functionalOf(row.name)} />
</tr>
);
})}
</tbody>
</Table>
</Wide>
</ChartSymbolHelp>
</>
);
}
@@ -0,0 +1,202 @@
"use client";
/**
* 盘面数据 · 特殊点 (issue #163): four groups — 特殊上升, 敏感点, Saham and
* Goel 特殊点. The open group is the one the board's 特殊点 layer draws; a
* row picks its point on the board. Every position is the engine's: sign-only
* points (64th Navamsa, 22nd Drekkana) show the sign and its lord and no
* degree. Saham lists the first nine and keeps the rest under 「全部」. Goel
* uses the printed rule with the nodes and shows the worked example's pick
* beside it.
*/
import { useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Table } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
import type { ChartBoardState } from "@/hooks/use-chart-board";
import { specialRows } from "@/lib/chart-board-special";
import {
GOEL_POLICY_ZH,
GOEL_SOURCE,
SAHAM_LEGACY,
SAHAM_SOURCE,
SPECIAL_SETS,
sahamFormulaText,
sahamFormulaTitle,
type SpecialSet,
} from "@/lib/chart-data-terms";
import type { ChartViewOk, ChartViewPoint } from "@/lib/chart-view-contract";
import { dragScroll } from "@/lib/drag-scroll";
function Wide({ children }: { children: ReactNode }) {
return <div className="chart-graha-table-wrap chart-drag" onPointerDown={dragScroll}>{children}</div>;
}
function Missing() {
return <p className="chart-page-note">这张表暂时没算出来。</p>;
}
function NameCell({ point, on, pick }: { point: ChartViewPoint; on: boolean; pick: () => void }) {
return (
<th scope="row">
<Button
type="button"
variant="ghost"
className="chart-graha-name h-auto px-0"
aria-pressed={on}
onClick={(event) => { event.stopPropagation(); pick(); }}
>
<span className="chart-point-tag">{point.short}</span>
{point.label}
<small>{point.english}</small>
</Button>
</th>
);
}
function position(point: ChartViewPoint): string {
return point.degreeInSign !== null ? `${point.signLabel} ${formatDegreeMinutes(point.degreeInSign)}` : point.signLabel;
}
type Column = { head: string; title?: string; num?: boolean; cell: (point: ChartViewPoint) => ReactNode };
function PointRows({ rows, board, columns }: { rows: ChartViewPoint[]; board: ChartBoardState; columns: Column[] }) {
const picked = board.selection?.kind === "point" ? board.selection.key : null;
return (
<Wide>
<Table className="chart-graha-table is-sticky">
<thead>
<tr>
<th scope="col">名称</th>
{columns.map((column) => <th key={column.head} scope="col" title={column.title}>{column.head}</th>)}
</tr>
</thead>
<tbody>
{rows.map((point) => {
const on = picked === point.key;
const pick = () => board.pickPoint(on ? null : point);
return (
<tr key={point.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
<NameCell point={point} on={on} pick={pick} />
{columns.map((column) => <td key={column.head} className={column.num ? "chart-graha-num" : undefined}>{column.cell(point)}</td>)}
</tr>
);
})}
</tbody>
</Table>
</Wide>
);
}
const NAKSHATRA_COLUMNS: Column[] = [
{ head: "月宿", cell: (point) => point.nakshatra ?? "—" },
{ head: "宿主", cell: (point) => point.nakshatraLord ?? "—" },
{ head: "足", num: true, cell: (point) => point.pada ?? "—" },
];
const LAGNA_COLUMNS: Column[] = [
{ head: "黄道经度", num: true, cell: position },
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
...NAKSHATRA_COLUMNS,
{ head: "传统含义", cell: (point) => <span className="chart-point-note">{point.note}</span> },
];
const SENSITIVE_COLUMNS: Column[] = [
{ head: "位置", num: true, cell: position },
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
{ head: "星座主", cell: (point) => point.signLord ?? "—" },
{ head: "参照", cell: (point) => point.reference ?? "—" },
...NAKSHATRA_COLUMNS,
];
const SAHAM_COLUMNS: Column[] = [
{ head: "黄道经度", num: true, cell: position },
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
{ head: "公式", title: SAHAM_SOURCE, cell: (point) => (point.formula
? <span title={sahamFormulaTitle(point.formula)}>{sahamFormulaText(point.formula)}</span>
: <span className="chart-point-legacy">{SAHAM_LEGACY}</span>) },
...NAKSHATRA_COLUMNS,
];
function GoelTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
const goel = view.points?.status === "ok" ? view.points.goel ?? null : null;
if (!goel) return <Missing />;
const points = specialRows(view, "goel") ?? [];
const picked = board.selection?.kind === "point" ? board.selection.key : null;
const strength = goel.strength;
const strengthTitle = strength
? `上升 / 七宫星座力:${GOEL_POLICY_ZH.text} ${strength.text.lagna} / ${strength.text.seventh};${GOEL_POLICY_ZH.example} ${strength.example.lagna} / ${strength.example.seventh}`
: undefined;
return (
<Wide>
<Table className="chart-graha-table is-sticky">
<thead>
<tr>
<th scope="col">名称</th>
<th scope="col" title={GOEL_SOURCE}>所取星 · {GOEL_POLICY_ZH.text}</th>
<th scope="col">位置</th>
<th scope="col">宫位</th>
<th scope="col" title={strengthTitle}>{GOEL_POLICY_ZH.example}</th>
</tr>
</thead>
<tbody>
{goel.rows.map((row) => {
const point = points.find((item) => item.key === row.key) ?? null;
const on = picked === row.key;
const pick = () => point && board.pickPoint(on ? null : point);
return (
<tr key={row.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
{point ? <NameCell point={point} on={on} pick={pick} /> : <th scope="row">{row.label}</th>}
<td className={row.planet ? "chart-graha-karaka" : "is-pending"} title={row.rule ?? undefined}>{row.planet ?? "无唯一取星"}</td>
<td className="chart-graha-num">{row.sign ?? "—"}</td>
<td className="chart-graha-num">{row.house ? `${row.house}宫` : "—"}</td>
<td className={row.differs ? "is-differs" : undefined} title={row.differs ? strengthTitle : undefined}>
{row.differs ? (row.comparePlanet ?? "无唯一取星") : "相同"}
</td>
</tr>
);
})}
</tbody>
</Table>
</Wide>
);
}
const COLUMNS: Readonly<Record<Exclude<SpecialSet, "saham" | "goel">, Column[]>> = {
lagna: LAGNA_COLUMNS,
sensitive: SENSITIVE_COLUMNS,
};
export function SpecialPointsPanel({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
const [sahamAll, setSahamAll] = useState(false);
const set = board.specialSet;
const rows = specialRows(view, set);
let body: ReactNode;
if (set === "goel") body = <GoelTable view={view} board={board} />;
else if (!rows || rows.length === 0) body = <Missing />;
else if (set === "saham") body = <PointRows rows={sahamAll ? rows : rows.filter((point) => point.primary)} board={board} columns={SAHAM_COLUMNS} />;
else body = <PointRows rows={rows} board={board} columns={COLUMNS[set]} />;
return (
<>
<div className="chart-board-toolbar">
<Tabs value={set} onValueChange={(value) => value && board.setSpecialSet(value as SpecialSet)}>
<TabsList variant="default" aria-label="特殊点分组" className="chart-data-subtabs" onPointerDown={dragScroll}>
{SPECIAL_SETS.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
</TabsList>
</Tabs>
{set === "saham" && rows && rows.length > 0 ? (
<Tabs value={sahamAll ? "all" : "first"} onValueChange={(value) => value && setSahamAll(value === "all")}>
<TabsList variant="default" aria-label="Saham 范围">
<TabsTrigger value="first" className="chart-board-tab">首批九项</TabsTrigger>
<TabsTrigger value="all" className="chart-board-tab">全部 <small>{rows.length}</small></TabsTrigger>
</TabsList>
</Tabs>
) : null}
</div>
{body}
</>
);
}
+9 -4
View File
@@ -8,10 +8,11 @@ import {
boardSource,
pointSelection,
pointsOf,
stepPoint,
type ChartBoardLayer,
type PointGroup,
} from "@/lib/chart-board-view";
import { layerPoints, stepPointInSet } from "@/lib/chart-board-special";
import type { SpecialSet } from "@/lib/chart-data-terms";
import type { ChartViewFailureStatus, ChartViewOk, ChartViewPoint } from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import type { ChartTypeId } from "@/lib/chart-view-labels";
@@ -57,6 +58,8 @@ export function useChartBoard({
const [layer, setLayerState] = useState<ChartBoardLayer>("none");
/** 标点 layer: every point of one group drawn on the board at once (issue #114). */
const [pointLayer, setPointLayerState] = useState<PointGroup | "none">("none");
/** The 特殊点 group the data card has open; the special layer draws only it (issue #163). */
const [specialSet, setSpecialSet] = useState<SpecialSet>("lagna");
useEffect(() => {
hydrateStyle();
@@ -96,7 +99,7 @@ export function useChartBoard({
if (!model) return;
setSelection((current) => {
if (current?.kind !== "point") return stepSelection(model, current, direction);
const next = stepPoint(view, current.group, current.key, direction);
const next = stepPointInSet(view, current.group, current.key, direction);
return next ? pointSelection(next) : current;
});
}, [model, view]);
@@ -104,12 +107,12 @@ export function useChartBoard({
/** Point tags on the board: the whole layer, plus a point picked from a table. */
const pointMarks = useMemo(() => {
if (type !== "D1") return [];
const layerRows = pointLayer === "none" ? [] : pointsOf(view, pointLayer) ?? [];
const layerRows = pointLayer === "none" ? [] : layerPoints(view, pointLayer, specialSet);
const picked = selection?.kind === "point" && !layerRows.some((row) => row.key === selection.key)
? (pointsOf(view, selection.group) ?? []).filter((row) => row.key === selection.key)
: [];
return [...layerRows, ...picked].map((row) => ({ key: row.key, short: row.short, label: row.label, sign: pointSelection(row).sign }));
}, [pointLayer, selection, type, view]);
}, [pointLayer, selection, specialSet, type, view]);
const selectPoint = useCallback((key: string) => {
const group = pointLayer !== "none" && pointsOf(view, pointLayer)?.some((row) => row.key === key)
@@ -159,6 +162,8 @@ export function useChartBoard({
onKeyDown,
pointLayer,
setPointLayer,
specialSet,
setSpecialSet,
pointMarks,
selectPoint,
pickPoint,
+35
View File
@@ -0,0 +1,35 @@
/**
* The 特殊点 groups on the board (issue #163). 特殊点 holds four groups; the
* board layer draws the one open in the data card, and stepping through
* points stays inside the picked point's group. Saham shows its first nine on
* the board; a point picked from 「全部」 is still drawn as the picked one.
*/
import { pointsOf, type PointGroup } from "./chart-board-view.ts";
import type { SpecialSet } from "./chart-data-terms.ts";
import type { ChartViewOk, ChartViewPoint } from "./chart-view-contract.ts";
export function pointSet(point: ChartViewPoint): SpecialSet {
return point.set ?? "lagna";
}
/** One group of 特殊点, or null while the points layer is missing. */
export function specialRows(view: ChartViewOk, set: SpecialSet): ChartViewPoint[] | null {
const rows = pointsOf(view, "special");
return rows ? rows.filter((point) => pointSet(point) === set) : null;
}
/** The points a board layer draws. */
export function layerPoints(view: ChartViewOk, group: PointGroup, set: SpecialSet): ChartViewPoint[] {
if (group !== "special") return pointsOf(view, group) ?? [];
return (specialRows(view, set) ?? []).filter((point) => point.primary !== false);
}
/** Next / previous point in the same table (for 特殊点, the same group). */
export function stepPointInSet(view: ChartViewOk, group: PointGroup, key: string, direction: 1 | -1): ChartViewPoint | null {
const all = pointsOf(view, group);
if (!all || all.length === 0) return null;
const current = all.find((point) => point.key === key);
const rows = group === "special" && current ? all.filter((point) => pointSet(point) === pointSet(current)) : all;
const index = rows.findIndex((point) => point.key === key);
return rows[(Math.max(0, index) + direction + rows.length) % rows.length] ?? null;
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Names and short glosses for the 盘面数据 card's engine columns (issue #163):
* Chara karaka roles, functional roles, sensitive points, Sahams and Goel's
* special points. Labels only; every value they describe comes from the
* engine. The Chinese gloss is a reading aid, not the role's whole meaning.
*/
import type { CharaKarakaKey } from "./chart-view-contract.ts";
export type KarakaTerm = { gloss: string; sanskrit: string; meaning: string };
export const KARAKA_TERMS: Readonly<Record<CharaKarakaKey, KarakaTerm>> = {
AK: { gloss: "自我", sanskrit: "Atmakaraka", meaning: "灵魂与核心自我" },
AmK: { gloss: "职事", sanskrit: "Amatyakaraka", meaning: "职事、辅佐与谋生" },
BK: { gloss: "手足", sanskrit: "Bhratrikaraka", meaning: "兄弟姐妹、同辈与勇气" },
MK: { gloss: "母亲", sanskrit: "Matrikaraka", meaning: "母亲、家庭与根基" },
PiK: { gloss: "父亲", sanskrit: "Pitrikaraka", meaning: "父亲与长辈" },
PK: { gloss: "子女", sanskrit: "Putrakaraka", meaning: "子女、学生与创造" },
GK: { gloss: "障碍", sanskrit: "Gnatikaraka", meaning: "亲族纷争、对手、疾病与障碍" },
DK: { gloss: "配偶", sanskrit: "Darakaraka", meaning: "配偶与合作伙伴" },
};
export const KARAKA_SCHEME_NOTE: Readonly<Record<"7" | "8", string>> = {
"7": "七星制:太阳至土星七颗星,按未取整的星座内度数由高到低排;罗睺、计都不计",
"8": "八星制:七星加罗睺,罗睺按 30° 减星座内度数排;计都不计",
};
/** Cell text for a role, e.g. 「AK · 自我」. */
export function karakaCell(key: CharaKarakaKey): string {
return `${key} · ${KARAKA_TERMS[key].gloss}`;
}
export function karakaTitle(key: CharaKarakaKey): string {
const term = KARAKA_TERMS[key];
return `${term.sanskrit}(${key}):${term.meaning}`;
}
export const KARAKA_TIED = "并列/角色待定";
export const NATURAL_ZH = {
benefic: "吉星",
malefic: "凶星",
phase: "吉星 · 随月相",
association: "吉星 · 随同宫",
} as const;
export const NATURAL_CONDITION_TITLE = {
phase: "月亮的自然吉凶随月相而定,本表没有判定",
association: "水星的自然吉凶随同宫星而定,本表没有判定",
} as const;
export const FUNCTIONAL_ZH = {
benefic: "偏吉/支持",
malefic: "偏凶/挑战",
neutral: "混合/待综合",
pending: "待综合",
} as const;
export const FUNCTIONAL_BASIS_TITLE = {
general: "BPHS 第34章宫主总则;关联条件未判断",
exception: "BPHS 第34章该上升的明文例外",
node: "依落宫、定位星及关联条件判断;本表未执行关联判断",
} as const;
export const FUNCTIONAL_CONFLICT_TITLE = "与自然属性方向相反,两层分开看";
export const IDENTITY_HEAD_TITLE =
"Yogakaraka:同一星分别主管 4/7/10 宫之一与 5/9 宫之一。Badhaka:动/固/双星座上升分别取 11/9/7 宫主。两者都不改变功能角色";
export function identityLabel(key: "yogakaraka" | "badhaka" | "kendradhipati", house: number | null): string {
if (key === "yogakaraka") return "Yogakaraka";
if (key === "badhaka") return `Badhaka · ${house ?? "—"}宫主`;
return "Kendradhipati 待综合";
}
export const IDENTITY_TITLE = {
yogakaraka: "严格 Yogakaraka:分别主管一个角宫(4/7/10)和一个三方宫(5/9)",
badhaka: "Badhaka 宫主:独立标签,不改变功能角色",
kendradhipati: "自然吉星主管角宫且不管 1/5/9 宫;Kendradhipati Dosha 的成立条件尚未判断",
} as const;
export type SpecialSet = "lagna" | "sensitive" | "saham" | "goel";
export const SPECIAL_SETS: ReadonlyArray<readonly [SpecialSet, string]> = [
["lagna", "特殊上升"],
["sensitive", "敏感点"],
["saham", "Saham"],
["goel", "Goel 特殊点"],
];
export type PointTerm = { label: string; short: string; english: string; note: string };
export const SENSITIVE_TERMS: Readonly<Record<string, PointTerm>> = {
Bhrigu_Bindu: { label: "Bhrigu 点", short: "BB", english: "Bhrigu Bindu", note: "罗睺到月亮顺行弧的中点,传统上用来看行运触发" },
Navamsa_64: { label: "第64 Navamsa", short: "N64", english: "64th Navamsa", note: "从月亮所在九分盘星座数起的第64分,传统敏感位置" },
Drekkana_22: { label: "第22 Drekkana", short: "D22", english: "22nd Drekkana", note: "从上升所在三分盘星座数起的第22分,传统敏感位置" },
};
/** PVR Table 74 meanings, as short Chinese glosses; the key is the engine's rule name. */
export const SAHAM_TERMS: Readonly<Record<string, { gloss: string; short: string }>> = {
Punya_Saham: { gloss: "福德", short: "福" },
Vidya_Saham: { gloss: "学业", short: "学" },
Yashas_Saham: { gloss: "声誉", short: "誉" },
Mitra_Saham: { gloss: "朋友", short: "友" },
Mahatmya_Saham: { gloss: "卓越", short: "卓" },
Asha_Saham: { gloss: "愿望", short: "愿" },
Samartha_Saham: { gloss: "能力与进取", short: "能" },
Bhratri_Saham: { gloss: "兄弟", short: "兄" },
Gaurava_Saham: { gloss: "尊重", short: "尊" },
Pitri_Saham: { gloss: "父亲", short: "父" },
Rajya_Saham: { gloss: "王权", short: "权" },
Matri_Saham: { gloss: "母亲", short: "母" },
Putra_Saham: { gloss: "子女", short: "子" },
Jeeva_Saham: { gloss: "生命", short: "命" },
Karma_Saham: { gloss: "行动与工作", short: "行" },
Roga_Saham: { gloss: "疾病", short: "病" },
Kali_Saham: { gloss: "大不顺", short: "厄" },
Sastra_Saham: { gloss: "学问", short: "典" },
Bandhu_Saham: { gloss: "亲属", short: "亲" },
Mrityu_Saham: { gloss: "终结", short: "终" },
Paradesa_Saham: { gloss: "异地", short: "异" },
Artha_Saham: { gloss: "财物", short: "财" },
Paradara_Saham: { gloss: "婚外关系", short: "外" },
Vanik_Saham: { gloss: "商业", short: "商" },
Karyasiddhi_Saham: { gloss: "事项成就", short: "成" },
Vivaha_Saham: { gloss: "婚姻", short: "婚" },
Santapa_Saham: { gloss: "忧伤", short: "忧" },
Sraddha_Saham: { gloss: "虔诚", short: "虔" },
Preeti_Saham: { gloss: "情感依恋", short: "情" },
Jadya_Saham: { gloss: "慢性病", short: "慢" },
Vyapara_Saham: { gloss: "经营", short: "营" },
Satru_Saham: { gloss: "敌人", short: "敌" },
Jalapatana_Saham: { gloss: "渡海", short: "渡" },
Bandhana_Saham: { gloss: "束缚", short: "缚" },
Apamrityu_Saham: { gloss: "意外终结", short: "意" },
Labha_Saham: { gloss: "收益", short: "益" },
};
export const SAHAM_SOURCE = "PVR Narasimha Rao《Vedic Astrology: An Integrated Approach》§28.8.1 表74";
export const SAHAM_LEGACY = "旧式候选,未核对来源";
const OPERAND_ZH: Readonly<Record<string, string>> = {
Sun: "日", Moon: "月", Mars: "火", Mercury: "水", Jupiter: "木", Venus: "金", Saturn: "土",
Ascendant: "升", Cancer15: "巨蟹15°", SunSignLord: "日座主", MoonSignLord: "月座主",
};
export function sahamOperand(operand: string): string {
const house = /^House(\d+)(Cusp|Lord)$/.exec(operand);
if (house) return `${house[1]}宫${house[2] === "Lord" ? "主" : ""}`;
if (OPERAND_ZH[operand]) return OPERAND_ZH[operand]!;
return SAHAM_TERMS[operand] ? `${SAHAM_TERMS[operand]!.gloss}点` : operand;
}
/** 「日 · 月 − 日 + 升」 plus 「+30°」 when the correction applied. */
export function sahamFormulaText(formula: { operands: readonly string[]; variant: "day" | "night"; plus30: boolean }): string {
const [a, b, c] = formula.operands.map(sahamOperand);
return `${formula.variant === "day" ? "日" : "夜"} · ${a} − ${b} + ${c}${formula.plus30 ? " +30°" : ""}`;
}
export function sahamFormulaTitle(formula: { variant: "day" | "night"; plus30: boolean }): string {
const time = formula.variant === "day" ? "白天出生,用日间公式" : "夜间出生,用该项的夜间公式";
const arc = formula.plus30 ? "第三项不在第二项到第一项的顺行弧上,加 30°" : "第三项在第二项到第一项的顺行弧上,不加 30°";
return `${time};${arc}`;
}
export const GOEL_TERMS: Readonly<Record<string, PointTerm>> = {
Goel_Brahma: { label: "Brahma", short: "梵", english: "Goel · Brahma", note: "Jaimini 寿限判断里的 Brahma,按 Goel 的规则取星" },
Goel_Maheshwara: { label: "Maheshwara", short: "自在", english: "Goel · Maheshwara", note: "Jaimini 寿限判断里的 Maheshwara,按 Goel 的规则取星" },
Goel_Rudra: { label: "Rudra", short: "楼陀", english: "Goel · Rudra", note: "Jaimini 寿限判断里的 Rudra,按 Goel 的规则取星" },
};
export const GOEL_POLICY_ZH = { text: "书面规则含交点", example: "示例排除交点" } as const;
export const GOEL_SOURCE = "V. P. Goel《Predicting through Jaimini Astrology》第10–17页;Karaka 仍按七星制";
+80 -5
View File
@@ -337,6 +337,23 @@ export const chartViewPointSchema = z.object({
sourceHouse: z.number().int().min(1).max(12).nullable(),
sourceLord: z.string().nullable(),
note: z.string().min(1),
/**
* 特殊点 only (issue #163): which of the four groups the row belongs to.
* Absent means the special lagnas and Yogi points.
*/
set: z.enum(["lagna", "sensitive", "saham", "goel"]).optional(),
/** Lord of the point's sign, for sign-only points (64th Navamsa, 22nd Drekkana). */
signLord: z.string().nullable().optional(),
/** What the point is counted from, as the engine reported it. */
reference: z.string().nullable().optional(),
/** Saham: one of the nine shown first; the rest sit under 「全部」. */
primary: z.boolean().optional(),
/** Saham: Table 74 operands A − B + C, the day/night variant and the +30° correction. */
formula: z.object({
operands: z.array(z.string().min(1)).length(3),
variant: z.enum(["day", "night"]),
plus30: z.boolean(),
}).nullable().optional(),
});
export const chartViewFriendshipSchema = z.object({
/** Planet → target → relation; natural and temporary −1/0/1, compound −2…2. */
@@ -352,10 +369,58 @@ export const chartViewFriendshipSchema = z.object({
})),
});
export const CHARA_KARAKA_KEYS = ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"] as const;
/** Seven-planet Chara karakas, highest in-sign degree first; null on a tie. */
export const CHARA_KARAKA_8_KEYS = ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"] as const;
export type CharaKarakaKey = (typeof CHARA_KARAKA_8_KEYS)[number];
/**
* One Chara karaka role, highest unrounded in-sign degree first. A role that
* bodies equal at full precision span has no planet and lists the candidates.
*/
export const chartViewKarakaSchema = z.object({
key: z.enum(CHARA_KARAKA_KEYS),
planet: z.string().min(1),
key: z.enum(CHARA_KARAKA_8_KEYS),
planet: z.string().min(1).nullable(),
candidates: z.array(z.string().min(1)),
});
export const chartViewKarakaTableSchema = z.object({
scheme: z.enum(["7", "8"]),
rows: z.array(chartViewKarakaSchema),
});
/** 行星表 functional columns: the engine's v2 ownership profile, one row per body. */
export const chartViewFunctionalSchema = z.object({
profile: z.string().min(1),
translation: z.string().min(1),
rows: z.array(z.object({
name: z.string().min(1),
natural: z.enum(["benefic", "malefic"]),
/** Moon / Mercury: the condition on their natural nature, never evaluated here. */
naturalCondition: z.enum(["phase", "association"]).nullable(),
houses: z.array(z.number().int().min(1).max(12)),
/** `neutral` is mixed / association-dependent; `pending` is the nodes, not judged. */
role: z.enum(["benefic", "malefic", "neutral", "pending"]),
basis: z.enum(["general", "exception", "node"]),
identities: z.array(z.object({
key: z.enum(["yogakaraka", "badhaka", "kendradhipati"]),
house: z.number().int().min(1).max(12).nullable(),
})),
conflict: z.boolean(),
})),
});
/** Goel's Brahma / Maheshwara / Rudra under the printed text, with the worked example beside it. */
export const chartViewGoelSchema = z.object({
rows: z.array(z.object({
key: z.string().min(1),
label: z.string().min(1),
planet: z.string().nullable(),
comparePlanet: z.string().nullable(),
differs: z.boolean(),
sign: z.string().nullable(),
house: z.number().int().min(1).max(12).nullable(),
rule: z.string().nullable(),
})),
/** Lagna and 7th sign strength under each policy (Brahma picks its anchor from them). */
strength: z.object({
text: z.object({ lagna: z.number(), seventh: z.number() }),
example: z.object({ lagna: z.number(), seventh: z.number() }),
}).nullable(),
});
export const chartViewPointsOkSchema = z.object({
status: z.literal("ok"),
@@ -363,8 +428,15 @@ export const chartViewPointsOkSchema = z.object({
special: z.array(chartViewPointSchema).nullable(),
arudha: z.array(chartViewPointSchema).nullable(),
friendship: chartViewFriendshipSchema.nullable(),
// Optional so a points layer cached before the karaka column still parses.
karakas: z.array(chartViewKarakaSchema).nullable().optional(),
// Optional so a points layer cached before these columns still parses.
karakaTables: z.object({
"7": chartViewKarakaTableSchema.nullable(),
"8": chartViewKarakaTableSchema.nullable(),
}).optional(),
functional: chartViewFunctionalSchema.nullable().optional(),
goel: chartViewGoelSchema.nullable().optional(),
/** Saham day/night at the birth time and place; null when the engine did not say. */
sahamDaytime: z.boolean().nullable().optional(),
});
/**
@@ -521,6 +593,9 @@ export type ChartViewAshtakavargaOk = z.infer<typeof chartViewAshtakavargaOkSche
export type ChartViewPointsOk = z.infer<typeof chartViewPointsOkSchema>;
export type ChartViewPoint = z.infer<typeof chartViewPointSchema>;
export type ChartViewKaraka = z.infer<typeof chartViewKarakaSchema>;
export type ChartViewKarakaTable = z.infer<typeof chartViewKarakaTableSchema>;
export type ChartViewFunctional = z.infer<typeof chartViewFunctionalSchema>;
export type ChartViewGoel = z.infer<typeof chartViewGoelSchema>;
export const CHART_VIEW_TABS = [
{ id: "vedic", label: "星盘" },
+10 -16
View File
@@ -13,9 +13,8 @@ import {
signZh,
} from "./chart-view-labels.ts";
import { panchangaNameZh } from "./panchanga-names-zh.ts";
import { CHARA_KARAKA_KEYS } from "./chart-view-contract.ts";
import { mapFunctional, mapGoel, mapKarakaTables, mapSahams, mapSensitive } from "./chart-view-points-engine.ts";
import type {
ChartViewKaraka,
ChartViewPoint,
ChartViewPointsOk,
ChartViewAshtakavargaOk,
@@ -381,18 +380,6 @@ function mapFriendship(block: unknown): ChartViewPointsOk["friendship"] {
return rows.length > 0 ? { rows } : null;
}
function karakaRows(block: unknown): ChartViewKaraka[] | null {
const packet = record(block);
if (!packet || packet.status !== "computed" || !Array.isArray(packet.rows)) return null;
const rows = packet.rows.flatMap((raw): ChartViewKaraka[] => {
const row = record(raw);
const key = CHARA_KARAKA_KEYS.find((item) => item === text(row?.key));
const planet = text(row?.planet);
return key && planet ? [{ key, planet }] : [];
});
return rows.length === CHARA_KARAKA_KEYS.length ? rows : null;
}
export function unavailablePoints(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: "这几张表暂时没算出来,稍后再试。" };
}
@@ -402,13 +389,20 @@ export function mapPoints(packet: Record<string, unknown> | null): ChartViewPoin
if (!packet || packet.success === false) return unavailablePoints("points_missing");
const special = pointRows(packet.special_lagnas, "special");
const yogi = pointRows(packet.yogi_points, "special");
// 特殊点 groups (issue #163): 特殊上升 rows carry no `set`; the others name theirs.
const sahams = mapSahams(packet.sahams);
const goel = mapGoel(packet.goel_points);
const specialRows = [...(special ?? []), ...(yogi ?? []), ...(mapSensitive(packet.sensitive_points) ?? []), ...(sahams?.rows ?? []), ...(goel?.points ?? [])];
const layer = {
status: "ok" as const,
upagrahas: pointRows(packet.upagrahas, "upagraha"),
special: special || yogi ? [...(special ?? []), ...(yogi ?? [])] : null,
special: specialRows.length > 0 ? specialRows : null,
arudha: arudhaRows(packet.arudha_padas),
friendship: mapFriendship(packet.friendship),
karakas: karakaRows(packet.chara_karakas),
karakaTables: mapKarakaTables(packet),
functional: mapFunctional(packet.functional_roles),
goel: goel?.goel ?? null,
sahamDaytime: sahams?.daytime ?? null,
};
if (!layer.upagrahas && !layer.special && !layer.arudha && !layer.friendship) return unavailablePoints("points_all_blocked");
return layer;
+3 -2
View File
@@ -252,9 +252,10 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
bhavaBala: enginePayload(houses),
}))
: null;
// Upagrahas need the birth moment and place; followUp carries both.
// Upagrahas need the birth moment and place; followUp carries both. Both
// karaka schemes come back so the 七星 / 八星 switch needs no second call.
const points = layers.has("points")
? await callEngine(input.postEngine, "/api/chart_points", followUp).then(layerPayload("points"))
? await callEngine(input.postEngine, "/api/chart_points", { ...followUp, karaka_scheme: "both" }).then(layerPayload("points"))
: null;
await dashaReady;
const lagnaPrecheck = await lagnaReady;
@@ -0,0 +1,243 @@
/**
* The points layer's engine columns (issue #163): Chara karaka tables in both
* schemes, the functional role table, and the 敏感点 / Saham / Goel groups of
* 特殊点. Reads and renames what the engine returned; nothing is computed.
* A block the engine blocked or left out is null, never filled in.
*/
import { asRecord as record } from "@/lib/guards";
import {
CHARA_KARAKA_8_KEYS,
CHARA_KARAKA_KEYS,
type CharaKarakaKey,
type ChartViewFunctional,
type ChartViewGoel,
type ChartViewKarakaTable,
type ChartViewPoint,
} from "./chart-view-contract.ts";
import { GOEL_TERMS, SAHAM_LEGACY, SAHAM_TERMS, SENSITIVE_TERMS, type PointTerm } from "./chart-data-terms.ts";
import { PLANET_ORDER, isSignName, planetZh, signZh } from "./chart-view-labels.ts";
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function computed(block: unknown): Record<string, unknown> | null {
const packet = record(block);
return packet && packet.status === "computed" ? packet : null;
}
function houseOf(value: unknown): number | null {
const house = finite(value);
return house !== null && Number.isInteger(house) && house >= 1 && house <= 12 ? house : null;
}
/** One scheme's table; null unless every role of the scheme is present. */
export function mapKarakaTable(block: unknown, scheme: "7" | "8"): ChartViewKarakaTable | null {
const packet = computed(block);
if (!packet || packet.scheme !== `chara_${scheme}` || !Array.isArray(packet.rows)) return null;
const keys: readonly CharaKarakaKey[] = scheme === "7" ? CHARA_KARAKA_KEYS : CHARA_KARAKA_8_KEYS;
const rows = packet.rows.flatMap((raw) => {
const row = record(raw);
const key = keys.find((item) => item === text(row?.key));
if (!row || !key) return [];
const planet = row.tied === true ? null : text(row.planet);
const candidates = Array.isArray(row.candidates) ? row.candidates.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
if (planet === null && candidates.length < 2) return [];
return [{ key, planet, candidates }];
});
return rows.length === keys.length && rows.every((row, index) => row.key === keys[index]) ? { scheme, rows } : null;
}
/** The seven-karaka table, and the eight-karaka one when the engine sent it. */
export function mapKarakaTables(packet: Record<string, unknown>) {
const seven = mapKarakaTable(packet.chara_karakas, "7");
const eight = mapKarakaTable(packet.chara_karakas_8, "8") ?? mapKarakaTable(packet.chara_karakas, "8");
return { "7": seven, "8": eight };
}
const ROLES = ["benefic", "malefic", "neutral", "pending"] as const;
const IDENTITIES = ["yogakaraka", "badhaka", "kendradhipati"] as const;
export function mapFunctional(block: unknown): ChartViewFunctional | null {
const packet = computed(block);
const profile = text(packet?.profile);
const translation = text(packet?.translation);
if (!packet || !profile || !translation || !Array.isArray(packet.rows)) return null;
const rows = packet.rows.flatMap((raw): ChartViewFunctional["rows"] => {
const row = record(raw);
const name = text(row?.planet);
const role = ROLES.find((item) => item === row?.functional);
const natural = row?.natural === "benefic" || row?.natural === "malefic" ? row.natural : null;
if (!row || !name || !role || !natural) return [];
const basisText = text(row.role_basis) ?? "";
return [{
name,
natural,
naturalCondition: row.natural_condition === "lunar_phase_not_evaluated" ? "phase"
: row.natural_condition === "association_not_evaluated" ? "association" : null,
houses: Array.isArray(row.owned_houses) ? row.owned_houses.flatMap((item) => (houseOf(item) ? [houseOf(item)!] : [])) : [],
role,
basis: role === "pending" ? "node" : basisText.includes("exception") ? "exception" : "general",
identities: (Array.isArray(row.identities) ? row.identities : []).flatMap((item) => {
const identity = record(item);
const key = IDENTITIES.find((value) => value === identity?.key);
return key ? [{ key, house: houseOf(identity?.house) }] : [];
}),
conflict: row.natural_functional_conflict === true,
}];
});
return rows.length > 0 ? { profile, translation, rows } : null;
}
function point(
row: Record<string, unknown>,
key: string,
set: NonNullable<ChartViewPoint["set"]>,
term: PointTerm,
extra: Partial<ChartViewPoint> = {},
): ChartViewPoint | null {
const sign = text(row.sign);
const house = houseOf(row.house);
if (!sign || !isSignName(sign) || house === null) return null;
const pada = finite(row.nakshatra_pada);
const lord = text(row.nakshatra_lord);
return {
key,
group: "special",
set,
label: term.label,
short: term.short,
english: term.english,
sign,
signLabel: signZh(sign),
degreeInSign: finite(row.degree_in_sign),
house,
nakshatra: text(row.nakshatra),
pada: pada !== null && pada >= 1 && pada <= 4 ? Math.trunc(pada) : null,
nakshatraLord: lord ? planetZh(lord) : null,
sourceHouse: null,
sourceLord: null,
note: term.note,
...extra,
};
}
const NODE_MODE_ZH: Readonly<Record<string, string>> = { mean: "平均交点", true: "真交点" };
function sensitiveReference(key: string, reference: Record<string, unknown> | null): string | null {
if (!reference) return null;
if (key === "Bhrigu_Bindu") {
const arc = text(reference.arc) === "forward_rahu_to_moon" ? "罗睺→月亮顺行弧中点" : text(reference.arc);
const mode = NODE_MODE_ZH[text(reference.node_mode) ?? ""] ?? "交点口径未注明";
return arc ? `${arc} · ${mode}` : null;
}
const base = text(reference.base_sign);
const count = finite(reference.count);
const division = text(reference.division);
const from = text(reference.from) === "Moon" ? "月亮" : text(reference.from) === "Lagna" ? "上升" : null;
return from && base && isSignName(base) && count && division ? `${from} ${division} ${signZh(base)}起第${count}` : null;
}
export function mapSensitive(block: unknown): ChartViewPoint[] | null {
const packet = computed(block);
if (!packet || !Array.isArray(packet.rows)) return null;
const rows = packet.rows.flatMap((raw) => {
const row = record(raw);
const key = text(row?.key);
const term = key ? SENSITIVE_TERMS[key] : undefined;
if (!row || !key || !term) return [];
const lord = text(row.lord);
const signOnly = row.precision === "sign";
const mapped = point(signOnly ? { sign: row.sign, house: row.house } : row, key, "sensitive", term, {
signLord: lord ? planetZh(lord) : null,
reference: sensitiveReference(key, record(row.reference)),
});
return mapped ? [mapped] : [];
});
return rows.length > 0 ? rows : null;
}
export function mapSahams(block: unknown): { rows: ChartViewPoint[]; daytime: boolean | null } | null {
const packet = computed(block);
if (!packet || !Array.isArray(packet.rows)) return null;
const rows = packet.rows.flatMap((raw) => {
const row = record(raw);
const key = text(row?.key);
if (!row || !key) return [];
const ruled = row.rule_backed === true;
const operands = Array.isArray(row.formula_operands) ? row.formula_operands.filter((item): item is string => typeof item === "string") : [];
const variant = row.formula_variant === "day" || row.formula_variant === "night" ? row.formula_variant : null;
const name = text(row.name) ?? key.replace(/_/g, " ");
const gloss = SAHAM_TERMS[key];
const term: PointTerm = {
label: gloss?.gloss ?? name,
short: gloss?.short ?? name.slice(0, 2),
english: name,
note: ruled ? `${gloss?.gloss ?? name}相关的 Saham 点` : SAHAM_LEGACY,
};
const mapped = point(row, key, "saham", term, {
primary: row.primary === true,
formula: ruled && operands.length === 3 && variant ? { operands, variant, plus30: row.plus_30_applied === true } : null,
});
return mapped ? [mapped] : [];
});
if (rows.length === 0) return null;
return { rows, daytime: typeof packet.is_daytime === "boolean" ? packet.is_daytime : null };
}
function strengthPair(value: unknown): { lagna: number; seventh: number } | null {
const row = record(value);
const lagna = finite(row?.lagna);
const seventh = finite(row?.seventh);
return lagna !== null && seventh !== null ? { lagna, seventh } : null;
}
function planetName(value: unknown): string | null {
const name = text(value);
return name && (PLANET_ORDER as readonly string[]).includes(name) ? name : null;
}
/** Goel table rows, and the executed points as board points in their planet's sign. */
export function mapGoel(block: unknown): { goel: ChartViewGoel; points: ChartViewPoint[] } | null {
const packet = computed(block);
if (!packet || !Array.isArray(packet.rows)) return null;
const points: ChartViewPoint[] = [];
const rows = packet.rows.flatMap((raw): ChartViewGoel["rows"] => {
const row = record(raw);
const key = text(row?.key);
const term = key ? GOEL_TERMS[key] : undefined;
if (!row || !key || !term) return [];
const planet = planetName(row.planet);
const comparePlanet = planetName(record(row.compare)?.planet);
const sign = text(row.sign);
const house = houseOf(row.house);
const placed = planet && sign && isSignName(sign) && house !== null;
if (placed) {
const mapped = point(row, key, "goel", { ...term, english: `${term.english} · ${planetZh(planet)}` });
if (mapped) points.push(mapped);
}
return [{
key,
label: term.label,
planet: planet ? planetZh(planet) : null,
comparePlanet: comparePlanet ? planetZh(comparePlanet) : null,
differs: row.differs === true,
sign: placed ? signZh(sign) : null,
house: placed ? house : null,
rule: text(row.rule),
}];
});
if (rows.length === 0) return null;
const strength = record(packet.lagna_seventh_strength);
const textPair = strengthPair(strength?.include_nodes_text);
const examplePair = strengthPair(strength?.exclude_nodes_worked_example);
return {
goel: { rows, strength: textPair && examplePair ? { text: textPair, example: examplePair } : null },
points,
};
}
@@ -100,16 +100,20 @@ function fakeBoard(view: ChartViewOk): ChartBoardState {
return { model, selection: null, select: () => {}, pickPoint: () => {} } as unknown as ChartBoardState;
}
test("the karaka column comes from chart_points, and a tie or a short table is left empty", () => {
// 原值:layer.karakas 是七行数组;引擎 blocked(同度)或缺行时整列为 null
// 新值:layer.karakaTables["7"];引擎 blocked 或缺行仍为 null,同度的角色由引擎标 tied(见
// chart-data-engine-columns-20261102.test.tsx),不再整列留空
// 原因:issue #163 占星师答复 2:默认七星、可切八星,同度只标「并列/角色待定」
test("the karaka column comes from chart_points, and a blocked or short table is left empty", () => {
const layer = mapPoints(chartPoints);
assert.equal(layer.status, "ok");
if (layer.status !== "ok") return;
assert.deepEqual(layer.karakas!.map((row) => row.key), KARAKA_KEYS);
assert.deepEqual(layer.karakas!.map((row) => row.planet), ranked);
const tied = mapPoints({ ...insight.chart_points, chara_karakas: { status: "blocked", reason: "tied_degrees" } });
assert.equal(tied.status === "ok" && tied.karakas, null);
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.key), KARAKA_KEYS);
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.planet), ranked);
const blocked = mapPoints({ ...insight.chart_points, chara_karakas: { status: "blocked", reason: "planets_missing" } });
assert.equal(blocked.status === "ok" && blocked.karakaTables!["7"], null);
const short = mapPoints({ ...insight.chart_points, chara_karakas: { ...charaKarakas, rows: charaKarakas.rows.slice(0, 6) } });
assert.equal(short.status === "ok" && short.karakas, null);
assert.equal(short.status === "ok" && short.karakaTables!["7"], null);
});
// 原值:星力在每颗星下写百分比,≥100% 绿字(good)、<100% 橙字(warn),缺行不标
@@ -387,12 +391,14 @@ test("盘面数据 has nine tabs, and 行星 lists the ascendant first with naks
picks: DEFAULT_DATA_PICKS,
onPicks: () => {},
}));
for (const label of ["盘面数据", "月宿 · 足", "宿主", "旺陷", "指标"]) assert.ok(markup.includes(label), label);
// 原值:列名「指标」 新值:「Chara Karaka」 原因:issue #163 占星师答复 2 改列名
for (const label of ["盘面数据", "月宿 · 足", "宿主", "旺陷", "Chara Karaka"]) assert.ok(markup.includes(label), label);
const rows = [...markup.matchAll(/<tr[^>]*><th scope="row">([^<]+)</g)].map((match) => match[1]);
assert.equal(rows[0], view.vedic.ascendant!.label);
const ak = ranked[0]!;
const akRow = view.vedic.planets.find((row) => row.name === ak)!;
assert.match(markup, new RegExp(`${akRow.label}</th>.*?chart-graha-karaka">AK<`));
// 原值:单元格只写「AK」 新值:「AK · 自我」,梵文全名与含义放在 title 原因:issue #163 占星师答复 2
assert.match(markup, new RegExp(`${akRow.label}</th>.*?chart-graha-karaka" title="Atmakaraka(AK):[^"]+">AK · 自我<`));
assert.doesNotMatch(markup, /点一行|在盘上定位/);
// The ascendant row's dignity cell is the same 「—」 with the help bubble as the natal table (DESIGN.md, 2026-10-25).
assert.match(markup, /chart-page-asc-status"><span data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
@@ -0,0 +1,204 @@
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 { ChartDataCard } from "../src/components/chart-board/chart-data-card.tsx";
import type { ChartBoardState } from "../src/hooks/use-chart-board.ts";
import { layerPoints, specialRows, stepPointInSet } from "../src/lib/chart-board-special.ts";
import { DEFAULT_DATA_PICKS, type ChartDataTab } from "../src/lib/chart-board-focus.ts";
import { natalBoard } from "../src/lib/chart-board-view.ts";
import type { SpecialSet } from "../src/lib/chart-data-terms.ts";
import { sahamFormulaText } from "../src/lib/chart-data-terms.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { mapPoints } from "../src/lib/chart-view-insight.ts";
Object.assign(globalThis, { React });
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
varga_full: Record<string, unknown>;
};
const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
chart_modules_shadbala: Record<string, unknown>;
chart_points: Record<string, unknown>;
};
/** Real `compute_chart_points` output for the same chart (see the fixture's `_source`). */
const engine = JSON.parse(readFileSync(new URL("./fixtures/chart-points-engine-columns-golden.json", import.meta.url), "utf8")) as {
chart_points: Record<string, unknown>;
goel_split: Record<string, unknown>;
tied_karakas: Record<string, unknown>;
};
const css = readFileSync(new URL("../src/components/chart-board/chart-data-engine.css", import.meta.url), "utf8");
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
async function okView(points: Record<string, unknown>): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-11-02",
layers: CHART_VIEW_LAYERS,
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insight.chart, modules: { shadbala: insight.chart_modules_shadbala } } };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
if (path === "/api/chart_points") return { status: "ok", payload: points };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
const fullPoints = { ...insight.chart_points, ...engine.chart_points };
function fakeBoard(view: ChartViewOk, specialSet: SpecialSet = "lagna"): ChartBoardState {
return {
model: natalBoard(view),
selection: null,
select: () => {},
pickPoint: () => {},
specialSet,
setSpecialSet: () => {},
} as unknown as ChartBoardState;
}
function render(view: ChartViewOk, tab: ChartDataTab, specialSet?: SpecialSet): string {
return renderToStaticMarkup(React.createElement(ChartDataCard, {
view,
board: fakeBoard(view, specialSet),
type: "D1",
tab,
onTab: () => {},
picks: DEFAULT_DATA_PICKS,
onPicks: () => {},
}));
}
function rowOf(markup: string, label: string): string {
const match = new RegExp(`<tr[^>]*><th scope="row">${label}</th>(.*?)</tr>`).exec(markup);
assert.ok(match, label);
return match[1]!;
}
test("chart_points maps both karaka schemes, the functional table and three new 特殊点 groups", () => {
const layer = mapPoints(fullPoints);
assert.equal(layer.status, "ok");
if (layer.status !== "ok") return;
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.key), ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]);
assert.deepEqual(layer.karakaTables!["8"]!.rows.map((row) => row.key), ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]);
assert.ok(layer.karakaTables!["8"]!.rows.some((row) => row.planet === "Rahu"));
assert.ok(!layer.karakaTables!["8"]!.rows.some((row) => row.planet === "Ketu"));
assert.equal(layer.functional!.profile, "bphs_ch34_general_with_sign_exceptions_v2");
assert.equal(layer.functional!.rows.length, 9);
assert.deepEqual(layer.functional!.rows.filter((row) => row.role === "pending").map((row) => row.name), ["Rahu", "Ketu"]);
const sets = (set: string) => layer.special!.filter((point) => (point.set ?? "lagna") === set);
assert.equal(sets("lagna").length, 10, "eight special lagnas plus Yogi and Avayogi, unchanged");
assert.deepEqual(sets("sensitive").map((point) => point.key), ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]);
assert.equal(sets("saham").filter((point) => point.primary).length, 9);
assert.equal(sets("saham").length, (engine.chart_points.sahams as { rows: unknown[] }).rows.length, "every computed Saham is kept");
assert.equal(sets("goel").length, 3);
});
test("行星 shows Chara Karaka as 「AK · 自我」 and functional roles beside the natural nature", async () => {
const view = await okView(fullPoints);
const markup = render(view, "planets");
for (const head of ["Chara Karaka", "自然属性", "管辖宫位", "功能角色", "补充身份"]) assert.ok(markup.includes(`>${head}</th>`), head);
assert.match(markup, /title="bphs_ch34_general_with_sign_exceptions_v2 · BPHS ch\.34, public English translation https:\/\/jyotishvidya\.com\/ch34\.htm[^"]*">功能角色</);
assert.ok(markup.includes("七星制") && markup.includes("八星制"));
// Leo: Mars owns 4 and 9, the strict Yogakaraka, and is also the 9th-lord Badhaka.
const mars = rowOf(markup, "火星");
assert.match(mars, /MK · 母亲/);
assert.match(mars, /凶星/);
assert.match(mars, /偏吉/支持/);
assert.match(mars, />Yogakaraka</);
assert.match(mars, />Badhaka · 9宫主</);
assert.match(rowOf(markup, "金星"), /Kendradhipati 待综合/);
assert.match(rowOf(markup, "罗睺"), /chart-functional-role is-pending[^>]*>待综合</);
assert.match(rowOf(markup, "计都"), />待综合</);
assert.match(rowOf(markup, "月亮"), /吉星 · 随月相/);
// 旺陷 keeps its own 「中性」 dignity label; the functional column never says it.
assert.doesNotMatch(markup, /chart-functional-role[^>]*>[^<]*中性/);
});
test("a karaka tie marks only the tied bodies and keeps every other role", async () => {
const view = await okView({ ...fullPoints, chara_karakas: engine.tied_karakas });
const markup = render(view, "planets");
assert.equal(markup.match(/并列/角色待定/g)?.length, 2);
assert.match(rowOf(markup, "太阳"), /title="与月亮同度,GK \/ DK 待定"/);
assert.match(rowOf(markup, "金星"), /AK · 自我/);
assert.match(rowOf(markup, "土星"), /BK · 手足|MK · 母亲|PK · 子女|AmK · 职事/);
});
test("特殊点 groups: sign-only points show no degree, Saham shows its formula, Goel shows the worked example", async () => {
const view = await okView({ ...fullPoints, goel_points: engine.goel_split });
for (const label of ["特殊上升", "敏感点", "Saham", "Goel 特殊点"]) assert.ok(render(view, "special").includes(label), label);
const sensitive = render(view, "special", "sensitive");
assert.match(sensitive, /罗睺→月亮顺行弧中点 · 平均交点/);
assert.match(sensitive, /月亮 D9 双鱼起第64/);
assert.match(sensitive, /上升 D3 白羊起第22/);
const n64 = /第64 Navamsa<small>64th Navamsa<\/small><\/button><\/th><td class="chart-graha-num">([^<]*)</.exec(sensitive);
assert.equal(n64?.[1], "双子", "the sign alone, no invented degree");
assert.doesNotMatch(sensitive, /灾难/);
const saham = render(view, "special", "saham");
assert.equal(saham.match(/<tr[^>]*aria-selected/g)?.length, 9);
assert.match(saham, /首批九项/);
assert.match(saham, /全部 <small>56<\/small>/);
assert.ok(saham.includes(sahamFormulaText({ operands: ["Moon", "Sun", "Ascendant"], variant: "day", plus30: false })));
assert.ok(saham.includes("日 · 11宫 − 11宫主 + 升 +30°"));
const goel = render(view, "special", "goel");
assert.match(goel, /所取星 · 书面规则含交点/);
assert.match(goel, /<td class="chart-graha-karaka"[^>]*>水星<\/td>.*?<td class="is-differs"[^>]*>木星<\/td>/);
assert.equal(goel.match(/>相同</g)?.length, 2);
});
test("the board's 特殊点 layer draws the open group, and stepping stays inside it", async () => {
const view = await okView(fullPoints);
assert.equal(layerPoints(view, "special", "lagna").length, 10);
assert.equal(layerPoints(view, "special", "sensitive").length, 3);
assert.equal(layerPoints(view, "special", "saham").length, 9, "the first nine on the board");
assert.equal(layerPoints(view, "special", "goel").length, 3);
assert.equal(layerPoints(view, "upagraha", "saham").length, 11);
const sensitive = specialRows(view, "sensitive")!;
const last = sensitive.at(-1)!;
assert.equal(stepPointInSet(view, "special", last.key, 1)?.key, sensitive[0]!.key);
});
test("the new styles use theme tokens only", () => {
assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b/i);
assert.match(css, /color-mix\(/);
assert.match(css, /\.is-pending/);
});
test("the chart view asks chart_points for both karaka schemes in one call", async () => {
const bodies: Record<string, unknown>[] = [];
await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-11-02",
layers: ["points"],
postEngine: async (path, body) => {
if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insight.chart } };
if (path === "/api/chart_points") { bodies.push(body); return { status: "ok", payload: fullPoints }; }
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(bodies.length, 1);
assert.equal(bodies[0]!.karaka_scheme, "both");
assert.equal(bodies[0]!.node_mode, "mean", "Bhrigu Bindu follows the chart's own node mode");
});
File diff suppressed because it is too large Load Diff
+7
View File
@@ -30,3 +30,10 @@ and period activation are independent layers. Neutral does not mean harmless;
functional benefic does not guarantee a favorable event. No death or outcome
prediction is inferred from this classification. The Yoga engine's natural
predicates and active Yoga rule library are not replaced by ownership roles.
Chart page layout (issue #163): `scripts/chart_view_points/functional_roles.py` lays this
profile out one row per body for the 盘面数据 table. It adds no rule: the
functional class is the profile's; Rahu/Ketu are `pending` (association not
evaluated); Yogakaraka and Badhaka (lord of `yoga_engine.badhaka_house`) are
separate labels; a natural benefic owning 4/7/10 and none of 1/5/9 is marked
Kendradhipati with `conditions_not_evaluated`.
+185 -23
View File
@@ -9,8 +9,21 @@ page does not have to run the full reading:
- Yogi / Avayogi: ``annual_native.yogi_sphuta.build_yogi_sphuta_local``
- arudha padas A1–A11 and UL: ``jaimini.calc_arudha_padas``
- friendship (natural, temporary, compound): ``jyotish_engine._build_planetary_friendship_snapshot``
- Chara karakas, seven-planet scheme: ``jaimini.calc_chara_karaka_7``; two of the
seven on the same in-sign degree block the table instead of picking an order
- Chara karakas: ``chart_view_points.karaka_rank.rank_chara_karakas`` (the ranking Goel
uses too), seven-karaka by default; ``karaka_scheme`` ``"8"`` gives the
eight-karaka table (Rahu reversed, Ketu out) and ``"both"`` adds it as
``chara_karakas_8``. Bodies equal at full precision mark only the roles they
span as tied; the other roles keep their planet (issue #163)
- functional roles: ``chart_view_points.functional_roles.functional_role_table`` over
``functional_benefics`` profile ``bphs_ch34_general_with_sign_exceptions_v2``
- sensitive points: Bhrigu Bindu from ``jyotish_engine._calc_sensitive_points``
(the chart's own Rahu, so it follows ``node_mode``), 64th Navamsa from the
Moon and 22nd Drekkana from the Lagna (``varga``); the last two are a sign
and its lord only
- Sahams: ``tajika.calc_all_sahams`` (PVR §28.8.1 Table 74; day or night from
the birth time and place, ``saham_daynight`` Hindu rising)
- Goel special points: ``goel_jaimini_strength.build_goel_chart_evidence``
under the printed-text node policy, with the worked-example policy beside it
Each block fails on its own (``status: blocked`` + reason); one failure never
fails the response.
@@ -83,12 +96,23 @@ SPECIAL_ORDER = (
"Bhava_Lagna", "Hora_Lagna", "Ghati_Lagna", "ViGhati_Lagna",
"Pranapada_Lagna", "Sree_Lagna", "Indu_Lagna", "Varnada_Lagna",
)
KARAKA_SHORT = {
"Atmakaraka": "AK", "Amatyakaraka": "AmK", "Bhratrikaraka": "BK", "Matrikaraka": "MK",
"Putrakaraka": "PK", "Gnatikaraka": "GK", "Darakaraka": "DK",
}
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
NINE = SEVEN + ("Rahu", "Ketu")
ARUDHA_ORDER = ("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL")
# Interface order for the Saham group: these nine first, the rest under 「全部」.
SAHAM_PRIMARY = (
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
)
GOEL_POINTS = ("Brahma", "Maheshwara", "Rudra")
GOEL_POLICIES = ("include_nodes_text", "exclude_nodes_worked_example")
def _karaka_scheme(body: dict[str, Any]) -> str:
value = str(body.get("karaka_scheme") or "7").strip()
if value not in ("7", "8", "both"):
raise ChartPointsInputError("karaka_scheme must be 7, 8 or both")
return value
def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
@@ -107,6 +131,9 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
second = int(body.get("second") or 0)
lat, lon, tz = _number(body, "lat"), _number(body, "lon"), _number(body, "tz")
ayanamsa = str(body.get("ayanamsa") or "raman")
node_mode = str(body.get("node_mode") or "unspecified")
karaka_scheme = _karaka_scheme(body)
longitudes = {name: value for name in NINE if (value := _longitude(planets.get(name))) is not None}
birth = datetime(year, month, day, hour, minute, second)
asc_sign = int(asc_lon // 30) % 12
@@ -170,24 +197,154 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
})
return {"status": "computed", "rows": rows}
def karakas() -> dict[str, Any]:
from jaimini import calc_chara_karaka_7
def karakas(scheme: int) -> dict[str, Any]:
from chart_view_points.karaka_rank import rank_chara_karakas
degrees = {name: lon % 30 for name in SEVEN if (lon := _longitude(planets.get(name))) is not None}
if len(degrees) < len(SEVEN):
ranking = rank_chara_karakas({name: lon % 30 for name, lon in longitudes.items()}, scheme)
if ranking["status"] != "computed":
return {"status": "blocked", "reason": ranking["reason"], "scheme": f"chara_{scheme}"}
rows = [
{
"key": row["key"], "name": row["name"], "rank": row["rank"], "planet": row["planet"],
"tied": row["tied"], "candidates": row["candidates"],
"degree_in_sign": round(row["degree_in_sign"], 6), "sort_degree": round(row["sort_degree"], 6),
}
for row in ranking["rows"]
]
return {
"status": "computed",
"scheme": f"chara_{scheme}",
"rows": rows,
"ties": ranking["ties"],
"sort_basis": ranking["sort_basis"],
"tie_tolerance": ranking["tie_tolerance"],
}
def functional() -> dict[str, Any]:
from chart_view_points.functional_roles import functional_role_table
return functional_role_table(SIGNS[asc_sign])
def sensitive() -> dict[str, Any]:
from jyotish_engine import _calc_sensitive_points
from varga import calc_22nd_drekkana, calc_64th_navamsa
rows = []
bhrigu = _calc_sensitive_points(planets).get("bhrigu_bindu")
if isinstance(bhrigu, dict) and isinstance(bhrigu.get("longitude"), (int, float)):
rows.append(_point(
"Bhrigu_Bindu", float(bhrigu["longitude"]), asc_sign,
precision="longitude", lord=bhrigu.get("lord"),
reference={"from": "Rahu", "to": "Moon", "arc": bhrigu.get("arc_mode"), "node_mode": node_mode},
))
moon = longitudes.get("Moon")
if moon is not None:
n64 = calc_64th_navamsa(moon)
sign = SIGNS.index(n64["sign"])
rows.append({
"key": "Navamsa_64", "sign": n64["sign"], "house": (sign - asc_sign) % 12 + 1,
"lord": n64["lord"], "precision": "sign",
"reference": {"from": "Moon", "base_sign": n64["base_navamsa_sign"], "division": "D9", "count": 64},
})
d22 = calc_22nd_drekkana(asc_lon)
sign = SIGNS.index(d22["sign"])
rows.append({
"key": "Drekkana_22", "sign": d22["sign"], "house": (sign - asc_sign) % 12 + 1,
"lord": d22["lord"], "precision": "sign",
"reference": {"from": "Lagna", "base_sign": d22["base_drekkana_sign"], "division": "D3", "count": 22},
})
return {"status": "computed", "rows": rows, "node_mode": node_mode}
def sahams() -> dict[str, Any]:
from tajika import calc_all_sahams
if len(longitudes) < len(NINE):
return {"status": "blocked", "reason": "planets_missing"}
# calc_chara_karaka_7 reports four decimals; equal there is a tie it would order arbitrarily.
if len({round(value, 4) for value in degrees.values()}) < len(SEVEN):
return {"status": "blocked", "reason": "tied_degrees"}
table = calc_chara_karaka_7(degrees).get("karaka_table") or {}
rows = sorted(
(
{"key": KARAKA_SHORT[name], "planet": row["planet"], "rank": row["rank"], "degree_in_sign": row["degree_in_sign"]}
for name, row in table.items() if name in KARAKA_SHORT
),
key=lambda row: row["rank"],
)
return {"status": "computed", "scheme": "chara_7", "rows": rows}
packet = calc_all_sahams(dict(longitudes), asc_lon, birth, "natal", lat=lat, lon=lon, tz=tz)
if packet.get("status") == "blocked":
return {"status": "blocked", "reason": str(packet.get("reason"))}
rows = []
for value in packet.values():
if not isinstance(value, dict) or not isinstance(value.get("longitude"), (int, float)):
continue
rule = value.get("formula_rule")
key = rule or str(value.get("name") or "").replace(" ", "_")
extra: dict[str, Any] = {
"name": value.get("name"),
"rule_backed": bool(rule),
"primary": key in SAHAM_PRIMARY,
}
if rule:
extra.update({
"formula_operands": value.get("formula_operands"),
"formula_variant": value.get("formula_variant"),
"plus_30_applied": value.get("zodiacal_order_exception_applied"),
})
rows.append(_point(key, float(value["longitude"]), asc_sign, **extra))
order = {key: index for index, key in enumerate(SAHAM_PRIMARY)}
rows.sort(key=lambda row: (order.get(row["key"], len(order)), not row["rule_backed"]))
daynight = packet.get("daynight_evidence") or {}
return {
"status": "computed",
"rows": rows,
"is_daytime": daynight.get("is_daytime"),
"daynight_method": daynight.get("method"),
"source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §28.8.1 Table 74",
}
def goel() -> dict[str, Any]:
try:
from scripts.goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
except ImportError:
from goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
if len(longitudes) < len(NINE):
return {"status": "blocked", "reason": "planets_missing"}
runs = {
policy: build_goel_chart_evidence(dict(longitudes), asc_lon, node_occupancy_policy=policy)
for policy in GOEL_POLICIES
}
main, compare = (runs[policy] for policy in GOEL_POLICIES)
if main["strength"].get("status") != "executed":
return {
"status": "blocked",
"reason": str(main["strength"].get("reason")),
"karaka_ties": main["strength"].get("karaka_ties"),
"profile": PROFILE,
}
rows = []
for name in GOEL_POINTS:
point, other = main["special_points"].get(name) or {}, compare["special_points"].get(name) or {}
planet = point.get("value") if point.get("status") == "executed" else None
row: dict[str, Any] = {
"key": f"Goel_{name}",
"point": name,
"status": point.get("status"),
"planet": planet,
"rule": point.get("rule"),
"reason": point.get("reason"),
"candidates": point.get("candidates"),
"compare": {
"status": other.get("status"),
"planet": other.get("value") if other.get("status") == "executed" else None,
"reason": other.get("reason"),
},
}
row["differs"] = (row["status"], planet) != (row["compare"]["status"], row["compare"]["planet"])
if planet in longitudes:
sign = int(longitudes[planet] // 30) % 12
row.update({"sign": SIGNS[sign], "house": (sign - asc_sign) % 12 + 1})
rows.append(row)
return {
"status": "computed",
"rows": rows,
"policy": GOEL_POLICIES[0],
"compare_policy": GOEL_POLICIES[1],
"lagna_seventh_strength": {policy: runs[policy].get("lagna_seventh_strength") for policy in GOEL_POLICIES},
"karaka_scheme": "chara_7",
"profile": PROFILE,
"source": SOURCE,
}
def friendship() -> dict[str, Any]:
from jyotish_engine import _build_planetary_friendship_snapshot
@@ -203,5 +360,10 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
"yogi_points": _guard(yogi),
"arudha_padas": _guard(arudha),
"friendship": _guard(friendship),
"chara_karakas": _guard(karakas),
"chara_karakas": _guard(lambda: karakas(8 if karaka_scheme == "8" else 7)),
**({"chara_karakas_8": _guard(lambda: karakas(8))} if karaka_scheme == "both" else {}),
"functional_roles": _guard(functional),
"sensitive_points": _guard(sensitive),
"sahams": _guard(sahams),
"goel_points": _guard(goel),
}
@@ -0,0 +1,100 @@
"""Per-planet functional role table for the chart page (issue #163).
Reads ``functional_benefics.derive_functional_benefic_malefic`` (profile
``bphs_ch34_general_with_sign_exceptions_v2``) and lays it out one row per
body, with the natural nature and two independent identities beside it.
Nothing here reclassifies a planet:
- ``functional`` is the profile's class for the seven. ``neutral`` in that
profile means mixed / association-dependent; associations are not evaluated
there, so it is never a "cancelled out" verdict.
- Rahu and Ketu are not in the seven-lord table. Their role depends on
placement, dispositor and associations, which nothing here evaluates, so
``functional`` is ``pending``.
- ``yogakaraka`` is the profile's strict field: distinct lordships, one of
4/7/10 and one of 5/9.
- ``badhaka`` is the lord of ``yoga_engine.badhaka_house`` (movable 11,
fixed 9, dual 7). It is a separate label; it never changes ``functional``.
- ``kendradhipati`` marks a natural benefic that owns 4, 7 or 10 and none of
1, 5, 9. The dosha's conditions (associations, placement, maraka role) are
not evaluated anywhere in the engine, so the label is always
``conditions_not_evaluated``.
``functional_benefics.py`` is part of the rectification production identity,
so this layout lives in the chart page package.
"""
from __future__ import annotations
from typing import Any
try:
from functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
from yoga_engine import BENEFICS, MALEFICS, badhaka_house
except ImportError: # package import (scripts.chart_view_points.functional_roles)
from scripts.functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
from scripts.yoga_engine import BENEFICS, MALEFICS, badhaka_house
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
NODES = ("Rahu", "Ketu")
# The engine's natural lists treat Moon and Mercury as benefics without
# checking the Moon's phase or Mercury's company; say so instead of judging it.
NATURAL_CONDITION = {"Moon": "lunar_phase_not_evaluated", "Mercury": "association_not_evaluated"}
TRANSLATION = "BPHS ch.34, public English translation https://jyotishvidya.com/ch34.htm (inspected 2026-10-02)"
def functional_role_table(ascendant: Any) -> dict[str, Any]:
base = derive_functional_benefic_malefic(ascendant)
asc_sign = normalize_sign(ascendant)
if base.get("status") != "used" or asc_sign is None:
return {"status": "blocked", "reason": "unknown_ascendant_sign", "profile": PROFILE, "source": SOURCE}
benefics = set(base["functional_benefics"])
malefics = set(base["functional_malefics"])
yogakarakas = set(base["yogakarakas"])
owned = base["owned_houses"]
b_house = badhaka_house(asc_sign)
b_lord = SIGN_LORDS[SIGNS[(SIGNS.index(asc_sign) + b_house - 1) % 12]] if b_house else None
rows = []
for planet in BODIES:
natural = "benefic" if planet in BENEFICS else "malefic" if planet in MALEFICS else None
houses = list(owned.get(planet, []))
identities: list[dict[str, Any]] = []
if planet in NODES:
functional = "pending"
basis = "node_placement_dispositor_association_not_evaluated"
else:
functional = "benefic" if planet in benefics else "malefic" if planet in malefics else "neutral"
basis = base["role_basis"].get(planet)
if planet in yogakarakas:
identities.append({"key": "yogakaraka"})
if planet == b_lord:
identities.append({"key": "badhaka", "house": b_house})
if (natural == "benefic" and set(houses) & {4, 7, 10} and not set(houses) & {1, 5, 9}):
identities.append({"key": "kendradhipati", "status": "conditions_not_evaluated"})
conflict = functional in ("benefic", "malefic") and natural is not None and functional != natural
rows.append({
"planet": planet,
"natural": natural,
"natural_condition": NATURAL_CONDITION.get(planet),
"owned_houses": houses,
"functional": functional,
"role_basis": basis,
"identities": identities,
"natural_functional_conflict": conflict,
})
return {
"status": "computed",
"ascendant": asc_sign,
"profile": PROFILE,
"source": SOURCE,
"translation": TRANSLATION,
"rule_reference": base.get("rule_reference"),
"yogakaraka_definition": base.get("yogakaraka_definition"),
"badhaka_rule": "movable 11 / fixed 9 / dual 7 (yoga_engine.badhaka_house)",
"badhaka_house": b_house,
"badhaka_lord": b_lord,
"kendradhipati_rule": "natural benefic owning 4/7/10 and none of 1/5/9; dosha conditions not evaluated",
"neutral_meaning": "mixed / association-dependent; associations not evaluated",
"rows": rows,
}
+113
View File
@@ -0,0 +1,113 @@
"""Chara karaka ranking with explicit ties (issue #163).
One ranking for the chart page's karaka column and Goel's full Jaimini
strength. Bodies are ordered by their unrounded in-sign degree, highest first.
In the eight-karaka scheme Rahu is included, Ketu is not, and Rahu ranks by
``30 - degree in sign``, the same reversal as ``jaimini.calc_chara_karaka_8``.
Two bodies whose sort degrees are still equal at full precision (within
``TIE_TOLERANCE``) form a tie. The roles they span are reported as contested
with every candidate; nothing is ordered by input or array order. Roles outside
a tie keep their planet.
``jaimini.calc_chara_karaka_7`` / ``calc_chara_karaka_8`` are part of the
rectification production identity and are not changed here; on untied input
this ranking gives the same order (tests/test_chara_karaka_rank.py).
"""
from __future__ import annotations
from typing import Any, Mapping
try:
from jaimini import KARAKA_7, KARAKA_8
except ImportError: # package import (scripts.chart_view_points.karaka_rank)
from scripts.jaimini import KARAKA_7, KARAKA_8
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
EIGHT = SEVEN + ("Rahu",)
TIE_TOLERANCE = 1e-10
KARAKA_SHORT = {
"Atmakaraka": "AK", "Amatyakaraka": "AmK", "Bhratrikaraka": "BK", "Matrikaraka": "MK",
"Pitrukaraka": "PiK", "Putrakaraka": "PK", "Gnatikaraka": "GK", "Darakaraka": "DK",
}
SCHEMES = {7: (SEVEN, KARAKA_7), 8: (EIGHT, KARAKA_8)}
def sort_degree(planet: str, degree_in_sign: float, scheme: int) -> float:
"""The value a body is ranked by: in-sign degree, Rahu reversed in the 8 scheme."""
raw = float(degree_in_sign) % 30.0
if scheme == 8 and planet == "Rahu":
return (30.0 - raw) % 30.0
return raw
def rank_chara_karakas(degrees: Mapping[str, float], scheme: int = 7) -> dict[str, Any]:
"""Rank the scheme's bodies; ``degrees`` holds in-sign degrees or longitudes.
Returns ``status: blocked`` when a body is missing. Otherwise ``rows`` has
one entry per role in rank order. A tied role has ``planet: None``,
``tied: True`` and the tied bodies in ``candidates``; ``ties`` lists each
tie once with the roles it spans.
"""
if scheme not in SCHEMES:
raise ValueError("chara karaka scheme must be 7 or 8")
bodies, roles = SCHEMES[scheme]
missing = [planet for planet in bodies if not isinstance(degrees.get(planet), (int, float)) or isinstance(degrees.get(planet), bool)]
if missing:
return {"status": "blocked", "scheme": scheme, "reason": "planets_missing", "missing": missing}
raw = {planet: float(degrees[planet]) % 30.0 for planet in bodies}
keyed = {planet: sort_degree(planet, raw[planet], scheme) for planet in bodies}
ordered = sorted(bodies, key=lambda planet: keyed[planet], reverse=True)
# Group runs of equal sort degrees. A run of one is an ordinary rank.
groups: list[list[str]] = []
for planet in ordered:
if groups and abs(keyed[groups[-1][-1]] - keyed[planet]) <= TIE_TOLERANCE:
groups[-1].append(planet)
else:
groups.append([planet])
rows: list[dict[str, Any]] = []
ties: list[dict[str, Any]] = []
by_planet: dict[str, dict[str, Any]] = {}
rank = 1
for group in groups:
span = list(range(rank, rank + len(group)))
tied = len(group) > 1
candidates = [planet for planet in bodies if planet in group] # fixed body order, not input order
span_keys = [KARAKA_SHORT[roles[r]] for r in span]
if tied:
ties.append({"planets": candidates, "roles": span_keys, "sort_degree": keyed[group[0]]})
for offset, r in enumerate(span):
name = roles[r]
planet = None if tied else group[0]
rows.append({
"rank": r,
"key": KARAKA_SHORT[name],
"name": name,
"planet": planet,
"tied": tied,
"candidates": candidates if tied else [group[0]],
"degree_in_sign": raw[group[offset]],
"sort_degree": keyed[group[offset]],
"rahu_reverse_applied": scheme == 8 and not tied and group[0] == "Rahu",
})
for planet in group:
by_planet[planet] = {
"role": None if tied else span_keys[0],
"tied": tied,
"contested_roles": span_keys if tied else [],
"tied_with": [p for p in candidates if p != planet],
}
rank += len(group)
return {
"status": "computed",
"scheme": scheme,
"rows": rows,
"ties": ties,
"planets": by_planet,
"order": ordered if not ties else None,
"tie_tolerance": TIE_TOLERANCE,
"sort_basis": "unrounded_degree_in_sign" if scheme == 7 else "unrounded_degree_in_sign_rahu_30_minus",
}
+21 -11
View File
@@ -8,6 +8,7 @@ import math
from scripts.trikona_raman import CLASSICAL, DIGNITY_PROFILE, _dignity, _validate_positions
from scripts.narayana_dasha import _rasi_aspects
from scripts.sthira_brahma_goel_short_method import SIGNS, SIGN_LORDS
from scripts.chart_view_points.karaka_rank import rank_chara_karakas
PROFILE = 'goel_full_jaimini_strength_ch1_v1'
SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'
@@ -21,10 +22,12 @@ def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include
_validate_positions(planet_longitudes)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
if any(math.isclose(degrees[a], degrees[b], rel_tol=0, abs_tol=1e-10)
for i, a in enumerate(ranked) for b in ranked[i + 1:]):
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions', profile_id=PROFILE)
# Shared with the chart page's karaka column: explicit ties, never input order.
ranking = rank_chara_karakas(degrees, 7)
if ranking['ties']:
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions',
profile_id=PROFILE, karaka_ties=ranking['ties'])
ranked = ranking['order']
ak = ranked[0]
karakas = dict(zip(ranked, KARAKA_POINTS))
@@ -72,12 +75,18 @@ def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include
claim_boundary='Named Goel full method with declared dignity ranges; not PL9 or all-lineage parity.')
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude, *, node_occupancy_policy='include_nodes_text'):
"""Strength and Brahma / Maheshwara / Rudra under one node occupancy policy.
The default is the printed p14 text (nodes count in Sthana). Passing
``exclude_nodes_worked_example`` selects the special points from the p15
worked-example totals instead, so a caller can show where they differ.
"""
if (type(ascendant_longitude) not in (int, float)
or not math.isfinite(ascendant_longitude)
or not 0 <= ascendant_longitude < 360):
raise ValueError('ascendant longitude must be a finite number in [0,360)')
strength = calculate_goel_strength(planet_longitudes)
strength = calculate_goel_strength(planet_longitudes, node_occupancy_policy=node_occupancy_policy)
result = dict(strength=strength, special_points={})
if strength['status'] != 'executed':
result['special_points'] = {
@@ -85,11 +94,12 @@ def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
for name in ('Brahma', 'Maheshwara', 'Rudra')
}
return result
strength['worked_example_node_exclusion'] = calculate_goel_strength(
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
strength['source_discrepancy'] = (
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
'Both numeric alternatives are preserved; the explicit text is the active profile.')
if node_occupancy_policy == 'include_nodes_text':
strength['worked_example_node_exclusion'] = calculate_goel_strength(
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
strength['source_discrepancy'] = (
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
'Both numeric alternatives are preserved; the explicit text is the active profile.')
lagna = int(float(ascendant_longitude) % 360 // 30)
seventh = (lagna + 6) % 12
planets = strength['planets']
+66
View File
@@ -0,0 +1,66 @@
"""Shared Chara karaka ranking (issue #163): explicit ties, same order as jaimini on untied input."""
from __future__ import annotations
import random
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for path in (ROOT, ROOT / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from chart_view_points.karaka_rank import rank_chara_karakas # noqa: E402
from jaimini import calc_chara_karaka_7, calc_chara_karaka_8 # noqa: E402
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
def _random_degrees(seed: int) -> dict[str, float]:
rng = random.Random(seed)
return {name: rng.uniform(0, 30) for name in BODIES}
@pytest.mark.parametrize("seed", range(20))
def test_untied_order_matches_jaimini_seven_and_eight(seed):
degrees = _random_degrees(seed)
seven = rank_chara_karakas(degrees, 7)
table7 = calc_chara_karaka_7(degrees)["karaka_table"]
assert [row["planet"] for row in seven["rows"]] == [table7[row["name"]]["planet"] for row in seven["rows"]]
eight = rank_chara_karakas(degrees, 8)
table8 = calc_chara_karaka_8(degrees)["karaka_table_8"]
assert [row["planet"] for row in eight["rows"]] == [table8[row["name"]]["planet"] for row in eight["rows"]]
assert "Ketu" not in eight["planets"]
def test_a_three_way_tie_spans_three_roles_and_names_no_planet():
degrees = {"Sun": 20.0, "Moon": 10.0, "Mars": 10.0, "Mercury": 10.0, "Jupiter": 25.0, "Venus": 5.0, "Saturn": 1.0}
result = rank_chara_karakas(degrees, 7)
assert result["order"] is None
assert result["ties"] == [{"planets": ["Moon", "Mars", "Mercury"], "roles": ["BK", "MK", "PK"], "sort_degree": 10.0}]
by_key = {row["key"]: row for row in result["rows"]}
assert [by_key[key]["planet"] for key in ("AK", "AmK", "GK", "DK")] == ["Jupiter", "Sun", "Venus", "Saturn"]
assert all(by_key[key]["planet"] is None for key in ("BK", "MK", "PK"))
assert result["planets"]["Mars"] == {"role": None, "tied": True, "contested_roles": ["BK", "MK", "PK"], "tied_with": ["Moon", "Mercury"]}
def test_tie_candidates_do_not_depend_on_input_order():
forward = {"Sun": 10.0, "Moon": 10.0, "Mars": 3.0, "Mercury": 4.0, "Jupiter": 5.0, "Venus": 6.0, "Saturn": 7.0}
backward = dict(reversed(list(forward.items())))
assert rank_chara_karakas(forward, 7)["rows"] == rank_chara_karakas(backward, 7)["rows"]
def test_rahu_ties_on_its_reversed_degree_in_the_eight_scheme():
degrees = {"Sun": 20.0, "Moon": 12.0, "Mars": 3.0, "Mercury": 4.0, "Jupiter": 5.0, "Venus": 6.0, "Saturn": 7.0, "Rahu": 18.0}
result = rank_chara_karakas(degrees, 8)
assert result["ties"][0]["planets"] == ["Moon", "Rahu"]
assert rank_chara_karakas(degrees, 7)["ties"] == []
def test_missing_body_blocks():
assert rank_chara_karakas({"Sun": 1.0}, 7)["status"] == "blocked"
with pytest.raises(ValueError):
rank_chara_karakas({}, 9)
+127 -3
View File
@@ -90,11 +90,135 @@ def test_chara_karakas_rank_the_seven_by_in_sign_degree():
assert [row["planet"] for row in rows] == expected
def test_chara_karakas_block_on_a_tie():
def test_chara_karakas_mark_only_the_tied_roles():
planets = {name: dict(row) for name, row in BODY["planets"].items()}
planets["Moon"]["lon"] = planets["Sun"]["lon"] % 30 + 30 * int(planets["Moon"]["lon"] // 30)
result = compute_chart_points({**BODY, "planets": planets})
assert result["chara_karakas"] == {"status": "blocked", "reason": "tied_degrees"}
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
# 原值: {"status": "blocked", "reason": "tied_degrees"},整列留空
# 新值: computed;只有 Sun / Moon 跨的两个角色标 tied,其余五个角色照常
# 原因: issue #163 占星师答复 2:同度不整列清空,只标「并列/角色待定」
assert result["status"] == "computed"
tied = [row for row in result["rows"] if row["tied"]]
assert [row["key"] for row in tied] == ["GK", "DK"]
assert all(row["planet"] is None and row["candidates"] == ["Sun", "Moon"] for row in tied)
assert result["ties"] == [{"planets": ["Sun", "Moon"], "roles": ["GK", "DK"], "sort_degree": pytest.approx(planets["Sun"]["lon"] % 30)}]
untied = [row for row in result["rows"] if not row["tied"]]
assert [row["planet"] for row in untied] == ["Venus", "Jupiter", "Mars", "Mercury", "Saturn"]
def test_chara_karakas_sort_by_unrounded_degree():
"""Equal at four decimals is not a tie; the old column blanked on rounded values."""
planets = {name: dict(row) for name, row in BODY["planets"].items()}
sign = 30 * int(planets["Moon"]["lon"] // 30)
planets["Moon"]["lon"] = sign + planets["Sun"]["lon"] % 30 + 0.00001
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
assert result["ties"] == []
assert [row["planet"] for row in result["rows"]][-2:] == ["Moon", "Sun"]
def test_karaka_scheme_parameter():
seven = compute_chart_points(BODY)
assert seven["chara_karakas"]["scheme"] == "chara_7"
assert "chara_karakas_8" not in seven
eight = compute_chart_points({**BODY, "karaka_scheme": "8"})["chara_karakas"]
assert eight["scheme"] == "chara_8"
assert [row["key"] for row in eight["rows"]] == ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]
assert "Ketu" not in [row["planet"] for row in eight["rows"]]
rahu = next(row for row in eight["rows"] if row["planet"] == "Rahu")
assert rahu["sort_degree"] == pytest.approx(30 - BODY["planets"]["Rahu"]["lon"] % 30, abs=1e-6)
both = compute_chart_points({**BODY, "karaka_scheme": "both"})
assert both["chara_karakas"]["scheme"] == "chara_7"
assert both["chara_karakas_8"]["rows"] == eight["rows"]
with pytest.raises(ChartPointsInputError):
compute_chart_points({**BODY, "karaka_scheme": "9"})
def test_functional_roles_keep_profile_identities_and_pending_nodes():
result = compute_chart_points(BODY)["functional_roles"]
assert result["status"] == "computed"
assert result["ascendant"] == "Leo"
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v2"
assert "jyotishvidya.com/ch34.htm" in result["translation"]
rows = {row["planet"]: row for row in result["rows"]}
assert list(rows) == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
# Leo: Mars owns 4 and 9 (strict yogakaraka); fixed sign, so the Badhaka is the 9th lord, also Mars.
assert rows["Mars"]["functional"] == "benefic"
assert {"key": "yogakaraka"} in rows["Mars"]["identities"]
assert {"key": "badhaka", "house": 9} in rows["Mars"]["identities"]
# Venus (3, 10): natural benefic owning a kendra and no trine; conditions are not evaluated.
assert {"key": "kendradhipati", "status": "conditions_not_evaluated"} in rows["Venus"]["identities"]
assert rows["Venus"]["functional"] == "malefic" and rows["Venus"]["natural_functional_conflict"] is True
for node in ("Rahu", "Ketu"):
assert rows[node]["functional"] == "pending"
assert rows[node]["identities"] == []
assert rows["Moon"]["natural_condition"] == "lunar_phase_not_evaluated"
def test_sensitive_points_follow_the_chart_nodes_and_keep_sign_only_points():
result = compute_chart_points({**BODY, "node_mode": "mean"})["sensitive_points"]
assert result["status"] == "computed"
rows = {row["key"]: row for row in result["rows"]}
assert list(rows) == ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]
rahu = BODY["planets"]["Rahu"]["lon"]
moon = BODY["planets"]["Moon"]["lon"]
assert rows["Bhrigu_Bindu"]["longitude"] == pytest.approx((rahu + ((moon - rahu) % 360) / 2) % 360, abs=1e-3)
assert rows["Bhrigu_Bindu"]["reference"] == {"from": "Rahu", "to": "Moon", "arc": "forward_rahu_to_moon", "node_mode": "mean"}
for key, anchor in (("Navamsa_64", "Moon"), ("Drekkana_22", "Lagna")):
assert rows[key]["precision"] == "sign"
assert "longitude" not in rows[key] and "degree_in_sign" not in rows[key]
assert rows[key]["reference"]["from"] == anchor
assert rows[key]["lord"]
def test_sahams_list_the_nine_first_and_keep_every_computed_row():
result = compute_chart_points(BODY)["sahams"]
assert result["status"] == "computed"
assert result["is_daytime"] is True # noon in Beijing
keys = [row["key"] for row in result["rows"]]
assert keys[:9] == [
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
]
assert [row["primary"] for row in result["rows"]].count(True) == 9
assert sum(row["rule_backed"] for row in result["rows"]) == 36
assert len(keys) == len(set(keys))
rows = {row["key"]: row for row in result["rows"]}
punya = rows["Punya_Saham"]
assert punya["formula_operands"] == ["Moon", "Sun", "Ascendant"] and punya["formula_variant"] == "day"
sun, moon, asc = (float(BODY["planets"]["Sun"]["lon"]), float(BODY["planets"]["Moon"]["lon"]), float(BODY["ascendant"]["lon"]))
expected = (moon - sun + asc) % 360 + (0 if (asc - sun) % 360 <= (moon - sun) % 360 else 30)
assert punya["longitude"] == pytest.approx(expected % 360, abs=1e-3)
assert rows["Karya_Saham"]["rule_backed"] is False
GOEL_SPLIT = {
"Sun": 102.29, "Moon": 123.1, "Mars": 90.57, "Mercury": 18.98, "Jupiter": 104.09,
"Venus": 127.86, "Saturn": 177.74, "Rahu": 120.14, "Ketu": 300.14,
}
def test_goel_points_default_to_the_printed_text_and_show_the_worked_example():
body = {**BODY, "planets": {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}, "ascendant": {"lon": 135.0}}
result = compute_chart_points(body)["goel_points"]
assert result["status"] == "computed"
assert (result["policy"], result["compare_policy"]) == ("include_nodes_text", "exclude_nodes_worked_example")
assert result["karaka_scheme"] == "chara_7"
rows = {row["point"]: row for row in result["rows"]}
brahma = rows["Brahma"]
assert (brahma["planet"], brahma["compare"]["planet"], brahma["differs"]) == ("Mercury", "Jupiter", True)
assert (brahma["sign"], brahma["house"]) == ("Aries", 9)
strength = result["lagna_seventh_strength"]
assert (strength["include_nodes_text"]["lagna"], strength["include_nodes_text"]["seventh"]) == (337.5, 352.5)
assert (strength["exclude_nodes_worked_example"]["lagna"], strength["exclude_nodes_worked_example"]["seventh"]) == (322.5, 292.5)
assert rows["Maheshwara"]["differs"] is False and rows["Rudra"]["differs"] is False
def test_goel_points_block_on_a_karaka_tie_and_say_which():
planets = {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}
planets["Moon"] = {"lon": 120 + GOEL_SPLIT["Sun"] % 30}
result = compute_chart_points({**BODY, "planets": planets, "ascendant": {"lon": 135.0}})["goel_points"]
assert result["status"] == "blocked"
assert result["karaka_ties"][0]["planets"] == ["Sun", "Moon"]
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
+38
View File
@@ -0,0 +1,38 @@
"""Functional role table (issue #163): a layout of the v2 profile, never a reclassification."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for path in (ROOT, ROOT / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from functional_benefics import SIGNS, derive_functional_benefic_malefic # noqa: E402
from chart_view_points.functional_roles import functional_role_table # noqa: E402
from yoga_engine import badhaka_house # noqa: E402
@pytest.mark.parametrize("sign", SIGNS)
def test_every_lagna_mirrors_the_profile(sign):
base = derive_functional_benefic_malefic(sign)
table = functional_role_table(sign)
rows = {row["planet"]: row for row in table["rows"]}
for planet in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
expected = "benefic" if planet in base["functional_benefics"] else "malefic" if planet in base["functional_malefics"] else "neutral"
assert rows[planet]["functional"] == expected
assert rows[planet]["owned_houses"] == base["owned_houses"][planet]
assert ({"key": "yogakaraka"} in rows[planet]["identities"]) == (planet in base["yogakarakas"])
for node in ("Rahu", "Ketu"):
assert rows[node]["functional"] == "pending"
badhaka = [planet for planet, row in rows.items() if any(item["key"] == "badhaka" for item in row["identities"])]
assert len(badhaka) == 1
assert rows[badhaka[0]]["owned_houses"].count(badhaka_house(sign)) == 1
def test_unknown_lagna_blocks():
assert functional_role_table("Nowhere")["status"] == "blocked"