fix: astrologer round-5 rulings 2/3/4/5/10/13 (BUG-1746..1752)
- Functional default = PVR Table 30 (functional_profile=pvr_table_30); the former product table is bphs_exception_profile, scored only when named (rectification names it, scores unchanged). profile_divergence shows both roles side by side; movable-lagna Moon by the table's paksha note; dual-lordship note; prior rulings BUG-1570/1590/#166 Q4 checked against Table 30. - Shadbala: six open items labelled parameter_sensitive; every option combination computed; only planets whose pass/fail flips get minimum_check=parameter_sensitive. - Vimshottari AD/PD rows carry parent_period_start / visible_period_start; the consult card cuts the balance MD from its full parent and clips at birth. - KP DBA: full first parent, clipped at birth (no balance rescale). - Mudda: balance fields + balance_method (angle default, time comparison apart), nominal_year_days, her crossing wording; Harsha combined blocked reason lists items. - D30 _meta describes the unequal parts (as her r40). - Golden recaptured with TTL=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
This commit is contained in:
@@ -23601,3 +23601,82 @@
|
||||
- 验证:`consult-card-calendar-narayana-20261108.test.ts`;`consultation-thinking-plan.test.ts` 更新(原值 / 新值 / 原因)。
|
||||
- 关联记录:BUG-1728
|
||||
- 修复版本:本地分支 `codex/phase2-chat-merge-1107`
|
||||
|
||||
## BUG-1746 | 功能吉凶默认用的是三方宫优先的产品表,不是占星师裁定的 PVR 表 30
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 3)
|
||||
- 影响面:`scripts/functional_benefics.py`(所有读功能吉凶的地方:数据卡、聊天、报告、年运、合盘、瑜伽引擎、星盘页功能角色表、事业分析);`functional_layer.py`、`mcp_server.py`、`jyotish_engine.py`、`jyotish_api_server.py`、`unified_consultation_orchestrator.py`、`yoga_engine.py`、`career_analysis.py`、`personal_calendar.py`、`annual_service/report_facts.py`、`chart_view_points/*`、前端 `consultation-evidence-card.ts`、`report-fact-tables.ts`、`consultation-workflow.ts`(字段白名单)。
|
||||
- 现象:BUG-1724 只给 PVR 表 30 加了参考档,打分与聊天仍用产品表;12 个上升中 6 格不同(白羊火星、双子水星、巨蟹土星、天秤金星、天蝎太阳、双鱼木星)。
|
||||
- 根因:产品表是唯一的默认档,PVR 表只供对照。
|
||||
- 修复:`derive_functional_benefic_malefic(..., functional_profile="pvr_table_30")` 为默认;原产品表另存为 `bphs_exception_profile`,只在调用方明确选中时参与打分。两档不同的格写进 `profile_divergence`(并列,不合并,`other_profile_scored=false`),卡上与报告表同时显示两档。表 30 不列动星座上升(白羊、天秤、摩羯)的月亮:按表下注释,盈月管角宫 → 功能凶,亏月 → 功能中性(`movable_lagna_moon`);调用方给 Sun / Moon 经度才算盈亏,未给时月亮不分类、不猜。双宫主综合判断写进 `dual_lordship_note`。旧裁定逐格对照(`prior_rulings`):BUG-1570 白羊 / 天蝎金星凶、BUG-1590 双子 / 处女土星混合与表 30 一致;#166 Q4 双子水星「上升主,支持」与表 30(中性)冲突,由分档解决(默认中性,产品表档保留吉,并列显示)。生时校正打分(`active_rectification_event_engine`、`chart_choice/astrologer_engine`、`research/precision_gate_lib`)明确选中 `bphs_exception_profile`,分数不变;三个文件在冻结身份里,哈希变了,需按 ERR-110 重冻或产品决定是否把校正也改成 PVR。
|
||||
- 影响范围(实测):`tests/celebrity_cases.json` 16 张公开盘中 10 张至少一颗星功能类别变;证据卡 golden 3 张盘中 2 张变(摩羯上升亏月月亮凶 → 中性;天蝎上升太阳吉 → 中性),其中一张盘父母(母)、婚姻、健康三条受冲依据各少一条「月亮是功能凶星」。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py`(表 30 逐格独立写出、6 格、月亮注释、旧裁定、校正明确选档);产品表测试改为明确选档(原值 / 新值 / 原因);`consult-upstream-functional-v2-20261002.test.ts`、`consult-pressure-lines-20261104.test.ts` 更新;golden 以 TTL=0 重采。
|
||||
- 防复发:默认档与产品表档各有独立测试;校正调用必须写明档名(测试按调用数核对)。
|
||||
- 关联记录:BUG-1724、BUG-1570、BUG-1590、BUG-1158、BUG-1181
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1747 | Shadbala 达标与否随未定参数翻转,聊天仍据此下好坏结论
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 4)
|
||||
- 影响面:新文件 `scripts/shadbala_service/sensitivity.py`(`shadbala.py` 在冻结身份里,未改);`shadbala_display.py`、`shadbala_service/api.py`、`jyotish_engine.py`(cmd_shadbala、全报告第 10 步、健康种子、evidence_snapshot 排名)、`jyotish_api_server.py`(星盘页摘要)、`pl9_reader_export.py`;卡上 `shadbala` 段与 `shadbalaColumns`;`references/product_runtime_contract.md` H 节。
|
||||
- 现象:她列的六个未定项(Saptavargaja/Moolatrikona 范围、太阳 Ayana 是否重复、月亮 Chesta 是否等于 Paksha、Drik 连续或整数相位、特殊相照替换或另加、水星/木星相照 1/4、1 或 1.25 倍)会改变总 Rupa,但卡上只写「达到 / 未达到 BPHS 最低要求」。
|
||||
- 修复:六项都标 `parameter_sensitive` 并写明未定问题;对每颗星把这些选项的全部组合实算一遍(先复现默认的 Saptavargaja 与 Drik 值,复现不了就标 `sensitivity_not_verified`),只有达标与否真的会翻转的星才 `minimum_check = parameter_sensitive`,并给出 `minimal_flip_sets` 与 `minimum_open_questions`;默认数值不变。合同加规则:这类星聊天与报告不得据达标与否下好坏结论。
|
||||
- 实测(3 张公开名人盘):每盘各 1–2 颗星翻转(其中一盘正是木星,单靠水星/木星相照倍数就翻转),其余星不变。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_shadbala_flip_is_computed_not_labelled`、`::test_display_turns_a_flipping_planet_into_parameter_sensitive`;`consult-evidence-card-v2-20260927.test.ts` 更新(原值 / 新值 / 原因)。
|
||||
- 关联记录:BUG-1622、BUG-1623、BUG-1581、BUG-1582、BUG-1208
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1748 | Vimshottari AD/PD 行没有分开「完整父段起点」与「出生后可见起点」
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 5)
|
||||
- 影响面:`jyotish_engine.cmd_dasha`(MD/AD/PD 行)、`chart_dasha/birth_moment.antardashas`、`jyotish_api_server._consultation_dasha_sub_periods`(咨询卡的 AD 边界)、卡上 `timing.vimshottari`。
|
||||
- 修复:每行带 `parent_period_start`(在完整父段时间轴上的原始起点)与 `visible_period_start`(出生后可见起点);出生裁剪只改可见起点,边界不动。咨询卡路径原先对平衡大运不裁剪(出生前的 AD 照列),现在从完整父段切好后按出生时刻裁剪,被裁的行在卡上同时带两字段。核查:咨询证据卡只摘取引擎日期,不重算。
|
||||
- 另见:星盘页(`frontend/src/lib/chart-view-mapper.ts`、`dasha-span.ts`)仍在前端按年数比例自己切 AD/PD,不是卡片层,本单未改,记为 BUG-1752。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_birth_moment_rows_keep_parent_start_when_clipped`。
|
||||
- 关联记录:BUG-1675、BUG-1718、BUG-1719
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1749 | KP DBA 首段大运按余额比例缩放,父段、子段与后续边界全被改变
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 10)
|
||||
- 影响面:`scripts/kp_system.calc_kp_dba_timeline`(KP 婚恋择期包 `kp_dba_timeline`)。
|
||||
- 根因:首段 MD 长度取 balance × 全长,再在缩放后的 MD 里按比例切 AD/PD,等于把首个父段重新缩放成新的完整时间轴。
|
||||
- 修复:先生成完整父段(起点 = 出生 − 已过部分),AD/PD 从完整父段切,再按出生时刻裁剪:出生前结束的行丢掉,跨出生的行可见起点裁到出生,`parent_period_start` 留原值;输出 `first_parent_period_start`、`first_segment_rule`。
|
||||
- 3 张公开名人盘的边界变化:MD 边界(含首段 MD 终点与之后所有 MD)不变;只有首段 MD 内的 AD/PD 变:三盘首段 MD 的 81 个 PD 行分别剩 5、69、63 个真实行(另 76、12、18 个在出生前、删去),保留的行边界全部改变(最大 299、361、1313 天);出生时所在 AD 由「与 MD 同名的第一个 AD」改为真实所在 AD。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_kp_first_segment_is_the_full_parent_clipped_at_birth`;`tests/test_marriage_precision_extensions.py` 照旧通过。
|
||||
- 关联记录:BUG-1748
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1750 | Mudda 余额没有写明算法;名义年字段名不明确;跨回归段文字不是她的原话;Harsha blocked 原因未列待确认项
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 2)
|
||||
- 影响面:`scripts/tajika_mudda.py`、`solar_return.py`、`jyotish_engine.cmd_tajika`、`annual_service/package.py`、`consultation_native_layers.py`、`tajika.calc_tajika_strength_layers`。
|
||||
- 修复:余额 `balance` 写明起运星、已过比例、剩余比例与 `balance_method`;默认按月宿角度(`nakshatra_arc_angle`,日期照旧由它决定),实际经过时间(`actual_elapsed_time`,用出生时刻与月亮进出月宿的时刻实算)放在 `balance_time_comparison`,`used_for_dates=false`,两者不合并;`nominal_year_days = 365.25` 明确写出并注明不等于真实回归间隔。跨下一次回归的段落文字改为「该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)」。Harsha 合分保持 blocked,`combined_strength_blocked_until` 与原因文字写明四个待确认项(四项分量定义、日间/夜间条件、最大值是否为 40、20+20 与 15+20 是否同一公式)。Harsha 单项分数照旧显示。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_mudda_balance_fields_and_nominal_year`、`::test_harsha_combined_blocked_reason_names_the_open_items`;`tests/test_annual_coverage_bug1722.py`、`consult-card-calendar-narayana-20261108.test.ts` 更新(原值 / 新值 / 原因)。
|
||||
- 关联记录:BUG-1722、BUG-1216
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1751 | D30 元数据 part_size = 1.0,误导为 30 等份
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 13 第 4 步;对照她的 r40 `varga.py`)
|
||||
- 影响面:`scripts/varga.calc_all_vargas` 的 `D30_Trimsamsa._meta`(只改元数据;落座与座内度数不变;该文件在冻结身份里,哈希变了)。
|
||||
- 修复:同她的 r40:`part_size=None`,加 `part_sizes`(奇 5/5/8/7/5、偶 5/7/8/5/5)、`source_segments`(含落座星座)、`calculation_method`、`degree_basis`、`mapping_source`。
|
||||
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_d30_meta_describes_unequal_parts`(元数据所写区间与实际落座一致)。
|
||||
- 关联记录:BUG-1589、BUG-1560
|
||||
- 修复版本:本地分支 `codex/functional-pvr-1108`
|
||||
|
||||
## BUG-1752 | 星盘页前端自己按年数比例切 Vimshottari AD/PD
|
||||
|
||||
- 状态:investigating(只记录,未改)
|
||||
- 首次发现 / 最近更新:2026-11-08(核查第五轮裁定 5「日期只来自原生 Dasha 引擎」时发现)
|
||||
- 影响面:`frontend/src/lib/chart-view-mapper.ts`(`addYears` 365.25 天)、`frontend/src/lib/dasha-span.ts`(`vimshottariSplit`),星盘页大运表与深读。
|
||||
- 现象:星盘页的 AD/PD 由前端用 MD 起止日期按年数比例重算,不是引擎给的边界;咨询证据卡不受影响(只摘引擎值)。
|
||||
- 待办:改为直接显示引擎 `antardasha_timeline` / `pratyantar_dasha_timeline`,并带 `parent_period_start` / `visible_period_start`。
|
||||
- 关联记录:BUG-1748
|
||||
- 修复版本:未修
|
||||
|
||||
@@ -106,8 +106,13 @@ export const EVIDENCE_CARD_LABELS = {
|
||||
arudhaColumns:
|
||||
"sign:pada 所在星座;pada_house_from_lagna:pada 所在宫(从本命上升按整宫数);lord:pada 星座的主星;"
|
||||
+ "lord_natal_house:该主星在本命盘所落的宫。pada 所在宫与主星落宫是两件事,写法:「A2 位于水瓶;其主星土星位于本命第 2 宫」",
|
||||
/** 占星师 2026-11-08 第五轮裁定 3 (PVR §13.2 Table 30 and its notes). */
|
||||
functionalProfileNote:
|
||||
"功能吉凶默认按 PVR 表 30(pvr_table_30);profile_divergence 列出与原产品表(bphs_exception_profile)不同的格,"
|
||||
+ "两档并列、不合并,另一档不参与判断;管两个星座的行星要把两宫综合判断(PVR §13.2)",
|
||||
shadbalaColumns:
|
||||
"rupas:六力总 Rupa;minimum_check:是否达到 BPHS 最低要求;level:网站按达标比(总 Rupa ÷ 最低要求)分档,"
|
||||
"rupas:六力总 Rupa;minimum_check:是否达到 BPHS 最低要求;minimum_check=parameter_sensitive:达标与否随未定 Shadbala 参数翻转"
|
||||
+ "(minimum_open_questions 列出未定项),不得据此下好坏结论;level:网站按达标比(总 Rupa ÷ 最低要求)分档,"
|
||||
+ "只说力量够不够用,不是吉凶,也不是 Ishta Phala;「强」不等于对用户有利,好坏看功能性吉凶与所主宫位",
|
||||
/**
|
||||
* Per-domain route status (产品 2026-11-07 占星师方案:回答模型拿到完整数据):
|
||||
@@ -907,7 +912,38 @@ const PLANET_ROW_KEYS = ["sign", "house", "degree_in_sign", "nakshatra", "retrog
|
||||
const FUNCTIONAL_KEYS = [
|
||||
"functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses",
|
||||
] as const;
|
||||
/**
|
||||
* 占星师 2026-11-08 第五轮裁定 3: the lists above are the default profile
|
||||
* (pvr_table_30). Where the former product table (bphs_exception_profile)
|
||||
* differs, each cell is [planet, default role, other role], side by side and
|
||||
* never merged; the other profile does not score. The movable-lagna Moon that
|
||||
* Table 30 leaves out carries the role its own paksha note gives, or none.
|
||||
*/
|
||||
function functionalProfileFields(value: unknown): JsonRecord {
|
||||
const source = record(value);
|
||||
const out: JsonRecord = {};
|
||||
const profile = source.functional_profile;
|
||||
if (typeof profile !== "string") return out;
|
||||
out.functional_profile = profile;
|
||||
const divergence = record(source.profile_divergence);
|
||||
const other = divergence.other_profile;
|
||||
const cells = Array.isArray(divergence.cells) ? divergence.cells.map(record) : [];
|
||||
if (typeof other === "string" && cells.length) {
|
||||
out.profile_divergence = {
|
||||
columns: ["planet", profile, other],
|
||||
other_profile_scored: false,
|
||||
cells: cells.map((cell) => [cell.planet, cell[profile], cell[other]] as Json[]),
|
||||
};
|
||||
}
|
||||
const moon = record(source.movable_lagna_moon);
|
||||
if (typeof moon.status === "string") {
|
||||
out.movable_lagna_moon = { status: moon.status, moon_paksha: copy(moon.moon_paksha) ?? null, role: copy(moon.role) ?? null };
|
||||
}
|
||||
out.functional_profile_note = EVIDENCE_CARD_LABELS.functionalProfileNote;
|
||||
return out;
|
||||
}
|
||||
const PERIOD_KEYS = ["lord", "start", "end"] as const;
|
||||
const CLIPPED_PERIOD_KEYS = ["parent_period_start", "visible_period_start"] as const;
|
||||
// BUG-1215: AD / PD drop `years` (it is end_age − start_age) so the legacy
|
||||
// algorithm label fits the 12,000-character card budget.
|
||||
const NARAYANA_SUB_PERIOD_KEYS = ["sign", "lord", "start_age", "end_age"] as const;
|
||||
@@ -995,7 +1031,8 @@ function arudhaHouses(base: EvidenceCard["base"], picked: JsonRecord): JsonRecor
|
||||
* `min_required` / `meets_minimum` / `level_source` restate the same two facts
|
||||
* and are left off the card to keep it within its size budget.
|
||||
*/
|
||||
const SHADBALA_CARD_KEYS = ["rupas", "minimum_check", "level"] as const;
|
||||
// 占星师 2026-11-08 第五轮裁定 4: `minimum_open_questions` only when minimum_check is parameter_sensitive.
|
||||
const SHADBALA_CARD_KEYS = ["rupas", "minimum_check", "minimum_open_questions", "level"] as const;
|
||||
|
||||
function shadbalaCardRows(shadbala: Json): Json {
|
||||
const rows = record(shadbala);
|
||||
@@ -1071,6 +1108,7 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
|
||||
}
|
||||
const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS);
|
||||
if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic");
|
||||
else Object.assign(functional, functionalProfileFields(natal.functional_benefic_malefic));
|
||||
const summary = record(natal.d9_summary);
|
||||
const d9: JsonRecord = {};
|
||||
if (typeof summary.lagna === "string") {
|
||||
@@ -1289,7 +1327,8 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: Ti
|
||||
const vimshottari: JsonRecord = {};
|
||||
const set = (key: string, value: unknown) => {
|
||||
const picked = pickKeys(value, PERIOD_KEYS);
|
||||
if (nonEmpty(picked)) vimshottari[key] = picked;
|
||||
const clipped = pickKeys(value, CLIPPED_PERIOD_KEYS);
|
||||
if (nonEmpty(picked)) vimshottari[key] = typeof clipped.parent_period_start === "string" ? { ...picked, ...clipped } : picked;
|
||||
};
|
||||
set("mahadasha", current.mahadasha);
|
||||
set("antardasha", current.antardasha);
|
||||
@@ -1303,6 +1342,10 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: Ti
|
||||
const rows = sub.boundaries.map((row): Json => {
|
||||
const picked = pickKeys(row, PERIOD_KEYS);
|
||||
const tuple = PERIOD_KEYS.map((key) => picked[key]);
|
||||
// 占星师 2026-11-08 第五轮裁定 5: a row clipped at birth keeps the engine's full parent start
|
||||
// beside its visible start (both copied, never recomputed here).
|
||||
const clipped = pickKeys(row, CLIPPED_PERIOD_KEYS);
|
||||
if (typeof clipped.parent_period_start === "string") return { ...picked, ...clipped };
|
||||
return tuple.every((value) => typeof value === "string") ? tuple as Json[] : picked;
|
||||
}).filter((row) => Array.isArray(row) || nonEmpty(row as JsonRecord));
|
||||
if (rows.length) vimshottari.antardashas_in_mahadasha = rows;
|
||||
|
||||
@@ -167,6 +167,23 @@ export function assembleReportFactTables(packet: unknown, language: ReportLangua
|
||||
if (planet(name)) add(table, `${sourcePath}.${bucket}[${index}]`, [label, L.planet(name)]);
|
||||
});
|
||||
}
|
||||
// 占星师 2026-11-08 第五轮裁定 3: the rows above are the default profile (PVR Table 30);
|
||||
// where the former product table differs its role is shown beside it, never merged.
|
||||
const divergence = record(raw.profile_divergence);
|
||||
const other = typeof divergence.other_profile === "string" ? divergence.other_profile : null;
|
||||
const roleLabel = (role: unknown) => (en
|
||||
? { benefic: "benefic", malefic: "malefic", neutral: "neutral" }
|
||||
: { benefic: "功能吉星", malefic: "功能凶星", neutral: "中性" })[String(role) as "benefic"] ?? (en ? "not classified" : "未分类");
|
||||
if (other) {
|
||||
list(divergence.cells).forEach((cell, index) => {
|
||||
const row = record(cell);
|
||||
if (!planet(row.planet)) return;
|
||||
add(table, `${sourcePath}.profile_divergence.cells[${index}]`, [
|
||||
en ? `${other} (comparison only, not scored)` : `${other}(只对照,不参与判断)`,
|
||||
`${L.planet(row.planet)}:${roleLabel(row[other])}`,
|
||||
]);
|
||||
});
|
||||
}
|
||||
} else if (id === "avasthas") {
|
||||
for (const [name, value] of entries(raw)) {
|
||||
if (!planet(name) || isBlocked(value)) continue;
|
||||
|
||||
@@ -372,6 +372,9 @@ const natalFoundationKeys = new Set([
|
||||
"components", "matrix", "matrixshape", "signs", "sources", "source", "method", "boundary",
|
||||
"functionalbeneficmalefic", "functionalbenefics", "functionalmalefics", "functionalneutrals",
|
||||
"yogakarakas", "ownedhouses", "ascendantsign", "effectonconfidence",
|
||||
// 占星师 2026-11-08 第五轮裁定 3: the profile name and, where the two profiles differ, both roles.
|
||||
"functionalprofile", "profiledivergence", "otherprofile", "otherprofilescored", "cells", "kind",
|
||||
"pvrtable30", "bphsexceptionprofile", "movablelagnamoon", "moonpaksha", "role",
|
||||
"yogas", "category", "arudhapadas", "padas", "a10", "a7", "ul", "al", "gulika", "mandi", "kakshya",
|
||||
// Evidence card v2: the engine writes the Arudha Lagna (AL) as pada `A1`.
|
||||
"a1",
|
||||
@@ -384,6 +387,8 @@ const timingKeys = new Set([
|
||||
"dasha", "dashaboundaries", "dashasubperiods", "narayanadasha", "status", "current", "next", "mahadasha",
|
||||
"antardasha", "pratyantardasha", "currentdasha", "period", "dashaperiod", "periods", "timeline",
|
||||
"boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent",
|
||||
// 占星师 2026-11-08 第五轮裁定 5: a row clipped at birth keeps its full parent start.
|
||||
"parentperiodstart", "visibleperiodstart",
|
||||
"activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source",
|
||||
"method", "confidence", "confidencecap", "summary", "conclusion", "fingerprint",
|
||||
"charadasha", "transits", "sadesati", "triggers", "triggercount", "searchperiod", "window", "sequence", "durationyears",
|
||||
|
||||
@@ -46,7 +46,10 @@ test("BUG-1731: the calendar-year rule is in the runtime contract (her Q2)", ()
|
||||
assert.match(block, /「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年/);
|
||||
assert.match(block, /timing\.annual_brief\.calendar_year/);
|
||||
assert.match(block, /「未来十二个月」「接下来一年」时,按用户实际请求的起止日期/);
|
||||
assert.match(block, /按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。/);
|
||||
// 原值:/按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。/
|
||||
// 新值:她的原话「该周期按原算法继续延伸,后续部分归入下一年盘解释」,原始延伸终点放括号里
|
||||
// 原因:占星师 2026-11-08 第五轮裁定 2
|
||||
assert.match(block, /该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)/);
|
||||
});
|
||||
|
||||
test("BUG-1732: the Narayana row carries the engine's step verdicts; unknown is never replaced by a guess", () => {
|
||||
|
||||
@@ -410,6 +410,15 @@ test("shadbala on the card: BPHS minimum check first, band labelled as the site'
|
||||
const rows = section.shadbala as Record<string, Json>;
|
||||
assert.deepEqual(Object.keys(rows).sort(), Object.keys(engine).sort(), chart.id);
|
||||
for (const [planet, row] of Object.entries(rows)) {
|
||||
// 原值:键恒为 ["rupas", "minimum_check", "level"],minimum_check 只取 达到 / 未达到。
|
||||
// 新值:未定参数会翻转达标与否的星 minimum_check = parameter_sensitive,并多一个 minimum_open_questions;
|
||||
// 其余星不变。原因:占星师 2026-11-08 第五轮裁定 4
|
||||
if (row.minimum_check === "parameter_sensitive") {
|
||||
assert.deepEqual(Object.keys(row), ["rupas", "minimum_check", "minimum_open_questions", "level"], `${chart.id} ${planet}`);
|
||||
assert.ok((row.minimum_open_questions as string[]).length > 0);
|
||||
assert.equal(engine[planet].minimum_check, "parameter_sensitive");
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(Object.keys(row), ["rupas", "minimum_check", "level"], `${chart.id} ${planet}`);
|
||||
assert.match(row.minimum_check as string, /^(达到|未达到) BPHS 最低要求$/);
|
||||
assert.match(row.level as string, /(网站分档)$/);
|
||||
|
||||
@@ -52,11 +52,15 @@ const signalsOf = (line: string) => {
|
||||
const EXPECTED: Record<string, Record<"mother" | "father" | "marriage" | "health", string[]>> = {
|
||||
barack_obama: {
|
||||
// Father left at two: 4 signals; mother close: 5. The count cannot tell them apart.
|
||||
mother: ["4 宫被火星照", "4 宫主火星落在对 4 宫不利的位置", "4 宫主火星是功能凶星", "月亮是功能凶星", "D12 4 宫有土星"],
|
||||
// 原值:mother / marriage / health 各含「月亮是功能凶星」(marriage 为「7 宫主月亮是功能凶星」),5 / 5 / 3 条。
|
||||
// 新值:去掉这三条(4 / 4 / 2 条)。摩羯上升、亏月:PVR 表 30 不列动星座上升的月亮,按表下注释亏月管角宫为功能中性;
|
||||
// 原产品表(bphs_exception_profile)判凶,只并列对照、不进判断。
|
||||
// 原因:占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30)
|
||||
mother: ["4 宫被火星照", "4 宫主火星落在对 4 宫不利的位置", "4 宫主火星是功能凶星", "D12 4 宫有土星"],
|
||||
father: ["9 宫有火星", "9 宫主水星被土星照", "9 宫主水星燃烧", "太阳被土星照"],
|
||||
marriage: ["7 宫被土星照", "7 宫主月亮是功能凶星", "金星落 6 宫", "伴侣代表星火星是功能凶星", "D9 7 宫有土星"],
|
||||
// Health control (no major illness): still three. The count cannot tell wear from illness.
|
||||
health: ["1 宫有土星", "太阳被土星照", "月亮是功能凶星"],
|
||||
marriage: ["7 宫被土星照", "金星落 6 宫", "伴侣代表星火星是功能凶星", "D9 7 宫有土星"],
|
||||
// Health control (no major illness): two after the profile change. The count cannot tell wear from illness.
|
||||
health: ["1 宫有土星", "太阳被土星照"],
|
||||
},
|
||||
elizabeth_taylor: {
|
||||
mother: ["4 宫有火星", "4 宫主土星落在对 4 宫不利的位置", "4 宫主土星是功能凶星", "月亮被土星照"],
|
||||
|
||||
@@ -45,9 +45,12 @@ test("goldens carry functional profile v2 from the real engine (BUG-1158)", () =
|
||||
// 新值:"bphs_ch34_general_with_sign_exceptions_v3_166q4"(上游 v3 表 + #166 Q4 双子 / 处女土星混合)
|
||||
// 原因:三方对照 2026-11-07:产品侧实现缺陷——标签写 v2,实际用的表不是 v2;golden 重新捕获
|
||||
// 名人回测 golden 未重新捕获(重捕获会带进之后新增的咨询层,卡片超出 20 000 预算,与本单无关),仍是旧标签。
|
||||
// 原值(2):本 golden 三盘为 "bphs_ch34_general_with_sign_exceptions_v3_166q4"
|
||||
// 新值(2):"pvr_integrated_approach_13_2_table_30"(functional_profile = pvr_table_30);名人回测 golden 仍未重捕获
|
||||
// 原因(2):占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30);golden 以 TTL=0 重新捕获
|
||||
layers.forEach((layer, index) => assert.equal(
|
||||
layer.profile,
|
||||
index < golden.charts.length ? "bphs_ch34_general_with_sign_exceptions_v3_166q4" : "bphs_ch34_general_with_sign_exceptions_v2",
|
||||
index < golden.charts.length ? "pvr_integrated_approach_13_2_table_30" : "bphs_ch34_general_with_sign_exceptions_v2",
|
||||
));
|
||||
// Scorpio (Taylor): explicit exceptions Mars mixed, Sun yoga-supporting.
|
||||
// 原值:functional_malefics ["Mercury", "Saturn"],functional_neutrals ["Mars", "Venus"](金星 7 / 12 宫主不因 12 宫单独判凶)
|
||||
@@ -55,10 +58,16 @@ test("goldens carry functional profile v2 from the real engine (BUG-1158)", () =
|
||||
// 原因:BUG-1570。BPHS 34.35 明文:天蝎上升的金星为凶;引擎补上这条例外,捕获 golden 2026-11-04 重生成
|
||||
const taylor = card(golden.charts.find((chart) => chart.id === "elizabeth_taylor")!.workflow, "career");
|
||||
const functional = taylor.base.natal.functional_benefic_malefic as Json;
|
||||
assert.deepEqual(functional.functional_benefics, ["Jupiter", "Moon", "Sun"]);
|
||||
// 原值(2):functional_benefics ["Jupiter", "Moon", "Sun"],functional_neutrals ["Mars"]
|
||||
// 新值(2):["Jupiter", "Moon"],["Mars", "Sun"];太阳在原产品表为吉,两档并列在 profile_divergence
|
||||
// 原因(2):占星师 2026-11-08 第五轮裁定 3(PVR 表 30 天蝎上升太阳为功能中性)
|
||||
assert.deepEqual(functional.functional_benefics, ["Jupiter", "Moon"]);
|
||||
assert.deepEqual(functional.functional_malefics, ["Mercury", "Saturn", "Venus"]);
|
||||
assert.deepEqual(functional.functional_neutrals, ["Mars"]);
|
||||
assert.deepEqual(functional.functional_neutrals, ["Mars", "Sun"]);
|
||||
assert.deepEqual(functional.yogakarakas, []);
|
||||
assert.equal(functional.functional_profile, "pvr_table_30");
|
||||
assert.deepEqual((functional.profile_divergence as Json).cells, [["Sun", "neutral", "benefic"]]);
|
||||
assert.equal((functional.profile_divergence as Json).other_profile_scored, false);
|
||||
// Virgo (Jobs): Jupiter challenging by exception, Sun (sole 12th lord) neutral.
|
||||
// 原值:functional_neutrals ["Sun"];新值:["Saturn", "Sun"];原因:BUG-1590(#166 第 4 条)处女上升土星(5、6 宫主)裁定为混合,
|
||||
// 捕获 golden 2026-11-04 按 staging 013f8895 重生成。
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -1841,7 +1841,8 @@ def _derive_functional_benefic_malefic(modules: Dict[str, Any]) -> Dict[str, Any
|
||||
chart = _safe_get(modules, "chart")
|
||||
ascendant = _safe_get(chart, "ascendant") if isinstance(chart, dict) else None
|
||||
from functional_layer import functional_layer_for_ascendant
|
||||
return functional_layer_for_ascendant(ascendant)
|
||||
planets = _safe_get(chart, "planets") if isinstance(chart, dict) else None
|
||||
return functional_layer_for_ascendant(ascendant, planets)
|
||||
|
||||
|
||||
def _derive_synastry_relationship_support(modules: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
@@ -20,7 +20,7 @@ SKILL.md 是占星师 skill 的原文(上游 732642856/yinduzhanxing),方
|
||||
<!-- 占星师 2026-11-07 裁定(她的 Q5 之一),不是产品自己的规则。 -->
|
||||
- 外部验证(MEVG / Global Web Evidence)与真实案例校准:她的 router 在一处把全网采集写成 optional,但 AGENTS 与 MEVG 门控要求「执行或明确标为 blocked」。本产品没有执行它们:照实写「本轮未执行」,审计表这两行写 blocked,绝不因为「optional」就当作已完成或略去。
|
||||
<!-- 产品规则,不是占星师的方法:A-08 「今年」按公历年、两张年盘在回归处分段(依据占星师 2026-11-07 Q2 裁定;字段由产品送到卡上,BUG-1731)。 -->
|
||||
- 「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年:公历年跨两张 Tajika 年盘,在太阳回归日分开。证据卡 `timing.annual_brief.calendar_year` 给出 `split_at_return`(分界的回归日)和两个 `tajika_years`(各自的 `coverage` 覆盖区间与落在本公历年的 `calendar_part`;`calendar_part.end` 是下一段的起点,不含当天)。回答时两段分开引用:回归日前按上一年盘,回归日后按本年盘,并写明分界日期;`role` 为 `annual_chart_not_in_this_card` 的那一年本轮只有区间、没有年盘内容,写「该年盘本轮未交付」,不拿本年盘的 Muntha、年主或 Mudda 顶替。用户说「未来十二个月」「接下来一年」时,按用户实际请求的起止日期(从本轮当前日期起算)写,不改成公历年或生日年。某个 Mudda 段越过下一次回归时,照卡上 `mudda_view.crossing_segment.text` 的写法:「按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。」
|
||||
- 「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年:公历年跨两张 Tajika 年盘,在太阳回归日分开。证据卡 `timing.annual_brief.calendar_year` 给出 `split_at_return`(分界的回归日)和两个 `tajika_years`(各自的 `coverage` 覆盖区间与落在本公历年的 `calendar_part`;`calendar_part.end` 是下一段的起点,不含当天)。回答时两段分开引用:回归日前按上一年盘,回归日后按本年盘,并写明分界日期;`role` 为 `annual_chart_not_in_this_card` 的那一年本轮只有区间、没有年盘内容,写「该年盘本轮未交付」,不拿本年盘的 Muntha、年主或 Mudda 顶替。用户说「未来十二个月」「接下来一年」时,按用户实际请求的起止日期(从本轮当前日期起算)写,不改成公历年或生日年。某个 Mudda 段越过下一次回归时,照卡上 `mudda_view.crossing_segment.text` 的写法:「该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)」(占星师 2026-11-08 第五轮裁定 2 原话)。
|
||||
<!-- 占星师 2026-11-07 裁定(她的 Q1),不是产品自己的规则;句式是她给的,`method_status` 字段由产品从引擎结果整理(BUG-1732)。 -->
|
||||
- Narayana 怎么参与应期:证据卡 `timing.narayana.method_status` 写明哪一步未定或有分歧(`unresolved_steps`:seed 起运、years 年数、dual_lord 双宫主、ad_direction 小运方向;引擎点不出是哪一步时写 `unknown`),`step_notes` 是每一步的原话,`open_items` 是尚待她裁定的方法项(B3/B4)。`chat_use` 为 `single_system_candidate` 时,Narayana 的相关分支有方法或实现分歧:把这一段写成单体系候选窗口,不写成「双体系确认」「两套同向」「两套都指向所以成立」,也不据此加强应期或提高证据等级;照她的句式写:「Vimshottari 提示这一阶段值得关注。Narayana 的相关分支尚有方法或实现分歧,因此这里保留为单体系候选窗口,暂不称为双体系确认。」并说明是哪一步未定(照 `step_notes` 写;`unknown` 就写「具体哪一步未定,本轮数据无法判定」,不自己猜一步)。`chat_use` 为 `no_judgment`(blocked)时,这一层不作任何结果性判断,也不拿旧算法(`narayana_legacy`)补位。完整的步骤结果放在后面的证据部分,正文只交代影响结论的那一步。
|
||||
<!-- 产品规则,不是占星师的方法:A-07 产品规则的公告与位置。 -->
|
||||
@@ -215,3 +215,11 @@ evidence_card.backstage says global web evidence and real-case calibration were
|
||||
- Q1 Narayana(BUG-1732):卡上 `timing.narayana.method_status` 按引擎结果逐步写出 seed / years / dual_lord / ad_direction 的状态与原话;B3/B4 未裁,运行时点不出是哪一步,记为 `unknown`。呈现按她的修改后 (a):单体系候选窗口,不称双体系确认;blocked 不作判断、不拿旧算法补位。「B」块 B-02 随之改写。
|
||||
- Q2 公历年(BUG-1731):引擎已算出的 `calendar_year`(两个 Tajika 年的覆盖区间与分界回归日)送到卡上 `timing.annual_brief.calendar_year`;写法见「A」块 A-08。
|
||||
- Q5 先答后证(BUG-1733):见「F」第 8 条。
|
||||
|
||||
## H. 占星师 2026-11-08 第五轮裁定在代码里的落实
|
||||
|
||||
- 裁定 3 功能吉凶:默认 `functional_profile = pvr_table_30`(PVR §13.2 表 30)。原产品表另存为 `bphs_exception_profile`,只在调用方明确选中时参与打分(生时校正打分明确选中它,分数不变,待重冻)。两档不同的格在 `profile_divergence` 并列,不合并;动星座上升(白羊、天秤、摩羯)的月亮表 30 不列,按表下注释:盈月管角宫 → 功能凶,亏月 → 功能中性;盈亏未知时不分类。双宫主按 PVR §13.2 综合判断(`dual_lordship_note`)。
|
||||
- 裁定 4 Shadbala:Saptavargaja/Moolatrikona 范围、太阳 Ayana 是否重复、月亮 Chesta 是否等于 Paksha、Drik 连续还是整数相位、特殊相照替换还是另加、水星/木星相照 1/4、1 或 1.25 倍,六项都标 `parameter_sensitive` 并写明未定问题。`scripts/shadbala_service/sensitivity.py` 把这些选项的全部组合实算一遍:某颗星的达标与否会翻转时,`minimum_check = parameter_sensitive`,并列出导致翻转的未定项(`minimum_open_questions`)。**规则:`minimum_check = parameter_sensitive` 的星,聊天与报告不得用「达标/不达标」下好坏结论,只写「是否达标取决于未定参数」。** 默认数值不变。
|
||||
- 裁定 5 Vimshottari:每行 AD/PD 带 `parent_period_start`(在完整父段时间轴上的原始起点)与 `visible_period_start`(出生后可见起点)。出生裁剪只改可见起点,不改 AD/PD 原始边界;日期只来自原生 Dasha 引擎,卡片层只摘取,不重算。
|
||||
- 裁定 10 KP 首段:先生成完整父段,再按出生时刻裁剪;不再把首段按余额比例缩放成新的完整时间轴。
|
||||
- 裁定 2 Mudda:余额字段写明起运星、已过比例、剩余比例与 `balance_method`;默认按月宿角度(`nakshatra_arc_angle`),实际经过时间另作对照,不混成一个数;`nominal_year_days = 365.25`;跨下一次回归的段落注明「该周期按原算法继续延伸,后续部分归入下一年盘解释」。Harsha 与合分保持 blocked,原因写明待确认项。
|
||||
|
||||
@@ -243,7 +243,9 @@ def _score_event(
|
||||
target_lords = _house_lords(ascendant_index, target_houses)
|
||||
if functional is None:
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||||
natal_chart["ascendant"].get("sign")
|
||||
natal_chart["ascendant"].get("sign"),
|
||||
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
|
||||
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
|
||||
)
|
||||
functional_benefics_set = set(functional.get("functional_benefics") or [])
|
||||
functional_malefics_set = set(functional.get("functional_malefics") or [])
|
||||
@@ -578,7 +580,9 @@ def build_candidate_static_context(
|
||||
except (KeyError, TypeError, ValueError):
|
||||
narayana_periods = None
|
||||
functional_profile = functional_benefics.derive_functional_benefic_malefic(
|
||||
chart["ascendant"].get("sign")
|
||||
chart["ascendant"].get("sign"),
|
||||
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
|
||||
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
|
||||
)
|
||||
narayana_antardasha_by_md = None
|
||||
if narayana_periods:
|
||||
|
||||
@@ -1056,6 +1056,10 @@ def _mudda_block(pack: dict, to_utc, zone, range_start, range_end) -> dict[str,
|
||||
completed_years=data.get("completed_years"),
|
||||
natal_moon_longitude=data.get("natal_moon_longitude"),
|
||||
natal_moon_nakshatra_number=data.get("natal_moon_nakshatra_number"),
|
||||
# 占星师 2026-11-08 第五轮裁定 2: balance fields, default angle method, time balance kept apart.
|
||||
balance=data.get("balance"), balance_method=data.get("balance_method"),
|
||||
balance_time_comparison=data.get("balance_time_comparison"),
|
||||
nominal_year_days=data.get("nominal_year_days"), nominal_year_days_note=data.get("nominal_year_days_note"),
|
||||
balance_profile=data.get("balance_profile"),
|
||||
balance_fraction_remaining=data.get("balance_fraction_remaining"),
|
||||
independent_of_year_lord=data.get("independent_of_year_lord"),
|
||||
@@ -1075,8 +1079,10 @@ def _mudda_block(pack: dict, to_utc, zone, range_start, range_end) -> dict[str,
|
||||
)
|
||||
|
||||
|
||||
CROSSING_TEXT = ("按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。"
|
||||
"回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。")
|
||||
# 原值:「按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。回归后的年度判断
|
||||
# 转入新年盘,旧周期延伸部分保留供方法对照。」新值:占星师原话,原始延伸终点放括号里。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 2。
|
||||
CROSSING_TEXT = "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至{end})"
|
||||
|
||||
|
||||
def _coverage_block(coverage: Any, to_utc, zone) -> dict[str, Any] | None:
|
||||
|
||||
@@ -367,7 +367,7 @@ def _natal_base(ctx: Any, birth_window: dict[str, Any], check: dict[str, Any]) -
|
||||
if isinstance(row, dict) and row.get("degree_raw") is not None}
|
||||
houses_of = {name: int(row["house"]) for name, row in planets.items()
|
||||
if isinstance(row, dict) and isinstance(row.get("house"), int)}
|
||||
functional = _module_call("functional_layer", "functional_layer_for_ascendant", asc)
|
||||
functional = _module_call("functional_layer", "functional_layer_for_ascendant", asc, planets) # PVR 表 30 默认(第五轮裁定 3)
|
||||
house_rows = []
|
||||
for house in range(1, 13):
|
||||
sign = ap.SIGNS[(lagna_idx + house - 1) % 12]
|
||||
@@ -438,7 +438,10 @@ def _functional_block(raw: Any, lagna_idx: int) -> dict[str, Any]:
|
||||
lagna_lord=raw.get("lagna_lord"), owned_houses=owned,
|
||||
# Maraka (2nd / 7th lord) is its own record; never an automatic minus (ruling item 2).
|
||||
marakas=sorted(planet for planet, houses in owned.items() if set(houses) & {2, 7}),
|
||||
profile=raw.get("profile"), nodes_note="罗计不单独定功能吉凶,参考定位星并保留自身位置",
|
||||
profile=raw.get("profile"), functional_profile=raw.get("functional_profile"),
|
||||
# 占星师 2026-11-08 第五轮裁定 3:两档不同时并列,不合并。
|
||||
profile_divergence=raw.get("profile_divergence"), movable_lagna_moon=raw.get("movable_lagna_moon"),
|
||||
dual_lordship_note=raw.get("dual_lordship_note"), nodes_note="罗计不单独定功能吉凶,参考定位星并保留自身位置",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ from typing import Dict, List, Any
|
||||
import math
|
||||
|
||||
try:
|
||||
from .functional_benefics import derive_functional_benefic_malefic, SIGN_LORDS
|
||||
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets, SIGN_LORDS
|
||||
from .affliction_evidence import collect_affliction_evidence
|
||||
except ImportError:
|
||||
from functional_benefics import derive_functional_benefic_malefic, SIGN_LORDS
|
||||
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets, SIGN_LORDS
|
||||
from affliction_evidence import collect_affliction_evidence
|
||||
|
||||
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
|
||||
@@ -89,7 +89,8 @@ def analyze_career(planets: Dict, asc_sign: str = 'Aries',
|
||||
analysis = {'indicators': [], 'strengths': [], 'challenges': [], 'timing': [], 'fields': []}
|
||||
if asc_sign not in SIGNS:
|
||||
raise ValueError('asc_sign must be a valid sign')
|
||||
functional = derive_functional_benefic_malefic(asc_sign)
|
||||
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
|
||||
functional = derive_functional_benefic_malefic(asc_sign, moon_paksha=moon_paksha_from_planets(planets))
|
||||
analysis['functional_benefic_malefic'] = functional
|
||||
analysis['assertion_label'] = 'single_system_inference'
|
||||
analysis['career_claim_gate'] = {
|
||||
|
||||
@@ -221,7 +221,9 @@ def _score_event(event: dict[str, Any], domain: str, row: dict[str, Any], vim: t
|
||||
|
||||
def _context(row: dict[str, Any], lat: float, lon: float, ayanamsa: str) -> dict[str, Any]:
|
||||
chart = row["chart"]
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(chart["ascendant"].get("sign"))
|
||||
# 第五轮裁定 3 后默认是 PVR 表 30;本打分沿用校正的原产品表,需明确选中(BUG-1746)。
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||||
chart["ascendant"].get("sign"), functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS)
|
||||
planets = chart.get("planets", {})
|
||||
at: datetime = row["at"]
|
||||
birth_hour = at.hour + at.minute / 60
|
||||
|
||||
@@ -97,11 +97,15 @@ def antardashas(mahadasha: dict[str, Any], *, visible_from: datetime | None = No
|
||||
sub_lord = VIMSHOTTARI_ORDER[(first + offset) % 9]
|
||||
end = parent_end if offset == 8 else start + total * (VIMSHOTTARI_YEARS[sub_lord] / 120.0)
|
||||
if visible_from is None or end > visible_from:
|
||||
visible = max(start, visible_from) if visible_from else start
|
||||
rows.append({
|
||||
"lord": sub_lord,
|
||||
"start": max(start, visible_from) if visible_from else start,
|
||||
"start": visible,
|
||||
"end": end,
|
||||
"years": VIMSHOTTARI_YEARS[sub_lord],
|
||||
# 占星师 2026-11-08 第五轮裁定 5: the boundary in the full parent, and what birth leaves visible.
|
||||
"parent_period_start": start,
|
||||
"visible_period_start": visible,
|
||||
})
|
||||
start = end
|
||||
return rows
|
||||
|
||||
@@ -223,7 +223,7 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
def functional() -> dict[str, Any]:
|
||||
from chart_view_points.functional_roles import functional_role_table
|
||||
|
||||
return functional_role_table(SIGNS[asc_sign])
|
||||
return functional_role_table(SIGNS[asc_sign], planets)
|
||||
|
||||
def sensitive() -> dict[str, Any]:
|
||||
from jyotish_engine import _calc_sensitive_points
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""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_v3_166q4``) and lays it out one row per
|
||||
Reads ``functional_benefics.derive_functional_benefic_malefic`` (default profile
|
||||
``pvr_table_30`` since 占星师 2026-11-08 第五轮裁定 3; the former product table
|
||||
``bphs_ch34_general_with_sign_exceptions_v3_166q4`` is shown beside it where the
|
||||
two differ) and lays it out one row per
|
||||
body, with the natural nature and two independent identities beside it.
|
||||
Nothing here reclassifies a planet:
|
||||
|
||||
@@ -29,10 +31,12 @@ 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 functional_benefics import (PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic,
|
||||
moon_paksha_from_planets, 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.functional_benefics import (PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic,
|
||||
moon_paksha_from_planets, normalize_sign)
|
||||
from scripts.yoga_engine import BENEFICS, MALEFICS, badhaka_house
|
||||
|
||||
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
@@ -43,13 +47,18 @@ NATURAL_CONDITION = {"Moon": "lunar_phase_not_evaluated", "Mercury": "associatio
|
||||
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)
|
||||
def functional_role_table(ascendant: Any, planets: Any = None) -> dict[str, Any]:
|
||||
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;原产品表(bphs_exception_profile)只并列对照。
|
||||
base = derive_functional_benefic_malefic(ascendant, moon_paksha=moon_paksha_from_planets(planets))
|
||||
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}
|
||||
return {"status": "blocked", "reason": "unknown_ascendant_sign", "functional_profile": base.get("functional_profile"), "source": SOURCE}
|
||||
benefics = set(base["functional_benefics"])
|
||||
malefics = set(base["functional_malefics"])
|
||||
neutrals = set(base["functional_neutrals"])
|
||||
divergence = base.get("profile_divergence") or {}
|
||||
other_profile = divergence.get("other_profile")
|
||||
other_by_planet = {cell["planet"]: cell.get(other_profile) for cell in divergence.get("cells") or []}
|
||||
yogakarakas = set(base["yogakarakas"])
|
||||
owned = base["owned_houses"]
|
||||
b_house = badhaka_house(asc_sign)
|
||||
@@ -64,7 +73,8 @@ def functional_role_table(ascendant: Any) -> dict[str, Any]:
|
||||
functional = "pending"
|
||||
basis = "node_placement_dispositor_association_not_evaluated"
|
||||
else:
|
||||
functional = "benefic" if planet in benefics else "malefic" if planet in malefics else "neutral"
|
||||
functional = ("benefic" if planet in benefics else "malefic" if planet in malefics
|
||||
else "neutral" if planet in neutrals else "pending")
|
||||
basis = base["role_basis"].get(planet)
|
||||
if planet in yogakarakas:
|
||||
identities.append({"key": "yogakaraka"})
|
||||
@@ -82,11 +92,18 @@ def functional_role_table(ascendant: Any) -> dict[str, Any]:
|
||||
"role_basis": basis,
|
||||
"identities": identities,
|
||||
"natural_functional_conflict": conflict,
|
||||
# Both profiles shown where they differ, never merged (第五轮裁定 3).
|
||||
**({"other_profile_functional": {"profile": other_profile, "functional": other_by_planet[planet]}}
|
||||
if planet in other_by_planet else {}),
|
||||
})
|
||||
return {
|
||||
"status": "computed",
|
||||
"ascendant": asc_sign,
|
||||
"profile": PROFILE,
|
||||
"profile": base.get("profile"),
|
||||
"functional_profile": base.get("functional_profile"),
|
||||
"profile_divergence": divergence,
|
||||
"movable_lagna_moon": base.get("movable_lagna_moon"),
|
||||
"dual_lordship_note": base.get("dual_lordship_note"),
|
||||
"source": SOURCE,
|
||||
"translation": TRANSLATION,
|
||||
"rule_reference": base.get("rule_reference"),
|
||||
|
||||
@@ -945,8 +945,10 @@ def _mudda_segments(periods: list, start: datetime, end: datetime, anchor: Any =
|
||||
return rows
|
||||
|
||||
|
||||
MUDDA_CROSSING_TEXT = ("按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。"
|
||||
"回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。")
|
||||
# 原值:「按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。回归后的年度判断
|
||||
# 转入新年盘,旧周期延伸部分保留供方法对照。」新值:占星师原话,原始延伸终点放括号里。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 2。
|
||||
MUDDA_CROSSING_TEXT = "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至{end})"
|
||||
|
||||
|
||||
def _mudda_view(data: dict, start: datetime, end: datetime) -> dict:
|
||||
|
||||
+252
-55
@@ -73,7 +73,10 @@ AMBIGUOUS_CELL_NOTES = {
|
||||
# (PROFILE = BPHS ch. 34 general ownership rules, trine-ownership first, plus sign-specific
|
||||
# overrides). The two agree on Virgo (Sun, Saturn neutral) and differ on 6 cells: Aries Mars,
|
||||
# Gemini Mercury, Cancer Saturn, Libra Venus, Scorpio Sun, Pisces Jupiter. Moon is not listed
|
||||
# by PVR for movable lagnas (depends on waxing / waning). Reference only; not wired into scoring.
|
||||
# by PVR for movable lagnas (depends on waxing / waning).
|
||||
# 原值:只供对照(reference_only),不进打分。新值:默认档 functional_profile = "pvr_table_30";
|
||||
# 上面的产品表另存为 "bphs_exception_profile",只有被明确选中时才参与打分。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 3。
|
||||
PVR_TABLE30_PROFILE = "pvr_integrated_approach_13_2_table_30"
|
||||
PVR_TABLE30_SOURCE = "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)"
|
||||
PVR_TABLE30 = {
|
||||
@@ -114,38 +117,95 @@ def normalize_sign(value: Any) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]:
|
||||
"""Return functional benefics, malefics and ownership roles for a Lagna."""
|
||||
asc_sign = normalize_sign(ascendant)
|
||||
if asc_sign is None:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"ascendant": ascendant if isinstance(ascendant, str) else None,
|
||||
"functional_benefics": [],
|
||||
"functional_malefics": [],
|
||||
"functional_neutrals": [],
|
||||
"yogakarakas": [],
|
||||
"owned_houses": {},
|
||||
"effect_on_confidence": "Functional layer blocked: unknown ascendant sign.",
|
||||
"source": SOURCE,
|
||||
}
|
||||
# ── 占星师 2026-11-08 第五轮裁定 3:两套具名功能吉凶体系 ─────────────────────────────
|
||||
# Default: PVR §13.2 Table 30. The product table above (BPHS ch. 34 general rules, trine first,
|
||||
# plus sign exceptions) is kept whole as "bphs_exception_profile" and scores only when a caller
|
||||
# names it. Where the two differ the output carries both roles side by side, never merged.
|
||||
FUNCTIONAL_PROFILE_PVR = "pvr_table_30"
|
||||
FUNCTIONAL_PROFILE_BPHS = "bphs_exception_profile"
|
||||
DEFAULT_FUNCTIONAL_PROFILE = FUNCTIONAL_PROFILE_PVR
|
||||
FUNCTIONAL_PROFILES = (FUNCTIONAL_PROFILE_PVR, FUNCTIONAL_PROFILE_BPHS)
|
||||
PROFILE_IDS = {FUNCTIONAL_PROFILE_PVR: PVR_TABLE30_PROFILE, FUNCTIONAL_PROFILE_BPHS: PROFILE}
|
||||
PROFILE_RULING = "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分"
|
||||
|
||||
# Movable lagnas whose Moon PVR Table 30 leaves out. Cancer is movable too, but there the Moon is the
|
||||
# lagna lord and the table lists him as a benefic.
|
||||
PVR_MOON_UNLISTED_LAGNAS = ("Aries", "Libra", "Capricorn")
|
||||
# PVR §13.2 (printed pp. 166-167; PDF pp. 178-179), the note under Table 30.
|
||||
PVR_MOVABLE_MOON_NOTE = (
|
||||
"Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or "
|
||||
"waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. "
|
||||
"Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral."
|
||||
)
|
||||
PVR_MOVABLE_MOON_NOTE_ZH = "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性"
|
||||
# PVR §13.2: "In the case of planets owning two rasis, we need to judiciously combine the two indications."
|
||||
DUAL_LORDSHIP_NOTE = (
|
||||
"PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。"
|
||||
)
|
||||
DUAL_LORDSHIP_NOTE_EN = (
|
||||
"PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; "
|
||||
"Table 30 is that integrated summary, not a mechanical one-house rule."
|
||||
)
|
||||
# Earlier astrologer rulings on the product table (BUG-1570, BUG-1590, #166 Q4 cell notes), checked
|
||||
# against Table 30 so none is silently dropped. Values: (ruling, product role, note).
|
||||
PRIOR_RULINGS = {
|
||||
("Aries", "Venus"): ("BUG-1570(BPHS 34.20)", "malefic", "两档一致:功能凶"),
|
||||
("Scorpio", "Venus"): ("BUG-1570(BPHS 34.35)", "malefic", "两档一致:功能凶"),
|
||||
("Gemini", "Saturn"): ("BUG-1590(#166 Q4)", "neutral", "两档一致:混合 / 中性"),
|
||||
("Virgo", "Saturn"): ("BUG-1590(#166 Q4)", "neutral", "两档一致:混合 / 中性"),
|
||||
("Gemini", "Mercury"): ("#166 Q4 歧义格说明", "benefic",
|
||||
"冲突:#166 Q4 说明「上升主,支持」,产品表判功能吉;PVR 表 30 判功能中性。"
|
||||
"按裁定 3 分档解决:默认 PVR 中性,bphs_exception_profile 保留功能吉,两者并列显示"),
|
||||
}
|
||||
|
||||
|
||||
def moon_paksha_from_longitudes(sun_longitude: Any, moon_longitude: Any) -> str | None:
|
||||
"""'waxing' when the Moon is 0-180 degrees ahead of the Sun, 'waning' otherwise; None without data."""
|
||||
try:
|
||||
elongation = (float(moon_longitude) - float(sun_longitude)) % 360.0
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return "waxing" if elongation < 180.0 else "waning"
|
||||
|
||||
|
||||
def moon_paksha_from_planets(planets: Any) -> str | None:
|
||||
"""Paksha from a planets mapping whose rows carry an absolute longitude."""
|
||||
if not isinstance(planets, dict):
|
||||
return None
|
||||
|
||||
def lon(name: str) -> Any:
|
||||
row = planets.get(name)
|
||||
if isinstance(row, (int, float)):
|
||||
return row
|
||||
if isinstance(row, dict):
|
||||
for key in ("lon", "longitude", "degree_raw", "abs_degree"):
|
||||
if isinstance(row.get(key), (int, float)):
|
||||
return row[key]
|
||||
sign, degree = normalize_sign(row.get("sign")), row.get("degree")
|
||||
if sign is not None and isinstance(degree, (int, float)):
|
||||
return SIGN_TO_INDEX[sign] * 30.0 + float(degree) if 0 <= degree < 30 else degree
|
||||
return None
|
||||
|
||||
return moon_paksha_from_longitudes(lon("Sun"), lon("Moon"))
|
||||
|
||||
|
||||
def _owned_houses(asc_sign: str) -> dict[str, list[int]]:
|
||||
asc_idx = SIGN_TO_INDEX[asc_sign]
|
||||
owned_houses: dict[str, list[int]] = {}
|
||||
owned: dict[str, list[int]] = {}
|
||||
for house_num in range(1, 13):
|
||||
sign = SIGNS[(asc_idx + house_num - 1) % 12]
|
||||
lord = SIGN_LORDS.get(sign)
|
||||
lord = SIGN_LORDS.get(SIGNS[(asc_idx + house_num - 1) % 12])
|
||||
if lord:
|
||||
owned_houses.setdefault(lord, []).append(house_num)
|
||||
owned.setdefault(lord, []).append(house_num)
|
||||
return owned
|
||||
|
||||
|
||||
def _bphs_exception_roles(asc_sign: str) -> dict[str, str]:
|
||||
"""The product table (PROFILE): BPHS ch. 34 general rules plus sign exceptions. Unchanged logic."""
|
||||
owned_houses = _owned_houses(asc_sign)
|
||||
trines = {1, 5, 9}
|
||||
kendras = {1, 4, 7, 10}
|
||||
challenging = {3, 6, 8, 11}
|
||||
benefics: set[str] = set()
|
||||
malefics: set[str] = set()
|
||||
yogakarakas: set[str] = set()
|
||||
neutrals: set[str] = set()
|
||||
|
||||
roles: dict[str, str] = {}
|
||||
for planet in PLANETS:
|
||||
houses = owned_houses.get(planet, [])
|
||||
if not houses:
|
||||
@@ -153,63 +213,200 @@ def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]:
|
||||
owns_trine = any(house in trines for house in houses)
|
||||
owns_kendra = any(house in kendras for house in houses)
|
||||
owns_challenge = any(house in challenging for house in houses)
|
||||
|
||||
# House 1 remains both angular and trinal, but cannot be reused as
|
||||
# two distinct lordships for this strict Yoga-karaka field.
|
||||
if set(houses) & {4, 7, 10} and set(houses) & {5, 9}:
|
||||
yogakarakas.add(planet)
|
||||
benefics.add(planet)
|
||||
roles[planet] = "benefic"
|
||||
elif owns_trine:
|
||||
benefics.add(planet)
|
||||
roles[planet] = "benefic"
|
||||
elif owns_challenge and 1 not in houses:
|
||||
malefics.add(planet)
|
||||
roles[planet] = "malefic"
|
||||
elif owns_kendra and planet in {"Jupiter", "Venus", "Mercury", "Moon"}:
|
||||
neutrals.add(planet)
|
||||
roles[planet] = "neutral"
|
||||
else:
|
||||
neutrals.add(planet)
|
||||
|
||||
roles[planet] = "neutral"
|
||||
eighth_lord = next((planet for planet, houses in owned_houses.items() if 8 in houses), None)
|
||||
if eighth_lord in {"Sun", "Moon"} and eighth_lord in malefics:
|
||||
malefics.remove(eighth_lord)
|
||||
neutrals.add(eighth_lord)
|
||||
|
||||
if eighth_lord in {"Sun", "Moon"} and roles.get(eighth_lord) == "malefic":
|
||||
roles[eighth_lord] = "neutral"
|
||||
for planet, role in ROLE_OVERRIDES.get(asc_sign, {}).items():
|
||||
benefics.discard(planet)
|
||||
malefics.discard(planet)
|
||||
neutrals.discard(planet)
|
||||
{"benefic": benefics, "malefic": malefics, "neutral": neutrals}[role].add(planet)
|
||||
roles[planet] = role
|
||||
return roles
|
||||
|
||||
|
||||
def _bphs_yogakarakas(asc_sign: str) -> list[str]:
|
||||
return sorted(planet for planet, houses in _owned_houses(asc_sign).items()
|
||||
if set(houses) & {4, 7, 10} and set(houses) & {5, 9})
|
||||
|
||||
|
||||
def _pvr_roles(asc_sign: str, moon_paksha: str | None) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
"""Table 30 roles; for an unlisted movable-lagna Moon the table's own paksha note decides."""
|
||||
_yk, ben, neu, mal = PVR_TABLE30[asc_sign]
|
||||
roles: dict[str, str] = {}
|
||||
for group, role in ((ben, "benefic"), (neu, "neutral"), (mal, "malefic")):
|
||||
for planet in group:
|
||||
roles[planet] = role
|
||||
moon_note = None
|
||||
if asc_sign in PVR_MOON_UNLISTED_LAGNAS:
|
||||
moon_role = {"waxing": "malefic", "waning": "neutral"}.get(str(moon_paksha or ""))
|
||||
moon_note = {
|
||||
"listed_in_table_30": False,
|
||||
"moon_paksha": moon_paksha if moon_role else None,
|
||||
"role": moon_role,
|
||||
"status": "derived_from_table_30_note" if moon_role else "paksha_unknown_not_classified",
|
||||
"rule": PVR_MOVABLE_MOON_NOTE,
|
||||
"rule_zh": PVR_MOVABLE_MOON_NOTE_ZH,
|
||||
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
|
||||
}
|
||||
if moon_role:
|
||||
roles["Moon"] = moon_role
|
||||
return roles, moon_note
|
||||
|
||||
|
||||
def _profile_roles(asc_sign: str, profile: str, moon_paksha: str | None) -> tuple[dict[str, str], list[str], dict[str, Any] | None]:
|
||||
if profile == FUNCTIONAL_PROFILE_PVR:
|
||||
roles, moon_note = _pvr_roles(asc_sign, moon_paksha)
|
||||
return roles, sorted(PVR_TABLE30[asc_sign][0]), moon_note
|
||||
return _bphs_exception_roles(asc_sign), _bphs_yogakarakas(asc_sign), None
|
||||
|
||||
|
||||
def _blocked(ascendant: Any, profile: str, reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"ascendant": ascendant if isinstance(ascendant, str) else None,
|
||||
"functional_profile": profile,
|
||||
"functional_benefics": [],
|
||||
"functional_malefics": [],
|
||||
"functional_neutrals": [],
|
||||
"yogakarakas": [],
|
||||
"owned_houses": {},
|
||||
"effect_on_confidence": reason,
|
||||
"source": SOURCE,
|
||||
}
|
||||
|
||||
|
||||
def derive_functional_benefic_malefic(
|
||||
ascendant: Any,
|
||||
*,
|
||||
functional_profile: str = DEFAULT_FUNCTIONAL_PROFILE,
|
||||
moon_paksha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Functional benefics, malefics and ownership roles for a Lagna under one named profile.
|
||||
|
||||
``functional_profile``: ``"pvr_table_30"`` (default, 占星师 2026-11-08 第五轮裁定 3) or
|
||||
``"bphs_exception_profile"`` (the former product table; scores only when a caller names it).
|
||||
``moon_paksha`` (``"waxing"`` / ``"waning"``) classifies the Moon for Aries / Libra / Capricorn
|
||||
lagnas under Table 30's own note; without it that Moon is left unclassified, never guessed.
|
||||
"""
|
||||
if functional_profile not in FUNCTIONAL_PROFILES:
|
||||
return _blocked(ascendant, functional_profile, f"Functional layer blocked: unknown functional_profile {functional_profile!r}.")
|
||||
asc_sign = normalize_sign(ascendant)
|
||||
if asc_sign is None:
|
||||
return _blocked(ascendant, functional_profile, "Functional layer blocked: unknown ascendant sign.")
|
||||
|
||||
other = FUNCTIONAL_PROFILE_BPHS if functional_profile == FUNCTIONAL_PROFILE_PVR else FUNCTIONAL_PROFILE_PVR
|
||||
roles, yogakarakas, moon_note = _profile_roles(asc_sign, functional_profile, moon_paksha)
|
||||
other_roles, other_yogakarakas, other_moon_note = _profile_roles(asc_sign, other, moon_paksha)
|
||||
owned_houses = _owned_houses(asc_sign)
|
||||
by_role = {role: sorted(p for p, r in roles.items() if r == role) for role in ("benefic", "malefic", "neutral")}
|
||||
other_by_role = {role: sorted(p for p, r in other_roles.items() if r == role) for role in ("benefic", "malefic", "neutral")}
|
||||
|
||||
divergence = []
|
||||
for planet in PLANETS:
|
||||
mine, theirs = roles.get(planet), other_roles.get(planet)
|
||||
if mine != theirs:
|
||||
divergence.append({
|
||||
"planet": planet,
|
||||
functional_profile: mine or "not_classified",
|
||||
other: theirs or "not_classified",
|
||||
"kind": "movable_lagna_moon_paksha" if planet == "Moon" and asc_sign in PVR_MOON_UNLISTED_LAGNAS else "table_cell",
|
||||
})
|
||||
|
||||
if functional_profile == FUNCTIONAL_PROFILE_PVR:
|
||||
role_basis = {
|
||||
planet: ("PVR §13.2 Table 30 note: movable-lagna Moon by paksha" if planet == "Moon" and moon_note
|
||||
else "PVR §13.2 Table 30")
|
||||
for planet in PLANETS
|
||||
}
|
||||
if moon_note and not moon_note["role"]:
|
||||
role_basis["Moon"] = "PVR §13.2 Table 30 does not list this Moon; paksha unknown, not classified"
|
||||
profile_source = PVR_TABLE30_SOURCE
|
||||
else:
|
||||
role_basis = {
|
||||
planet: "BPHS ch34 sign-specific exception" if planet in ROLE_OVERRIDES.get(asc_sign, {})
|
||||
else "BPHS ch34 general ownership; associations not evaluated"
|
||||
for planet in PLANETS
|
||||
}
|
||||
profile_source = "BPHS ch. 34 general ownership rules plus sign exceptions (references/functional-role-profile.md)"
|
||||
|
||||
prior = []
|
||||
for (sign, planet), (ruling, product_role, note) in PRIOR_RULINGS.items():
|
||||
if sign != asc_sign:
|
||||
continue
|
||||
pvr_role = (roles if functional_profile == FUNCTIONAL_PROFILE_PVR else other_roles).get(planet)
|
||||
prior.append({"planet": planet, "ruling": ruling, FUNCTIONAL_PROFILE_BPHS: product_role,
|
||||
FUNCTIONAL_PROFILE_PVR: pvr_role, "agrees": pvr_role == product_role, "resolution": note})
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"status": "used",
|
||||
"ascendant": asc_sign,
|
||||
"functional_benefics": sorted(benefics),
|
||||
"functional_malefics": sorted(malefics),
|
||||
"functional_neutrals": sorted(neutrals - benefics - malefics),
|
||||
"yogakarakas": sorted(yogakarakas),
|
||||
"functional_profile": functional_profile,
|
||||
"functional_profile_ruling": PROFILE_RULING,
|
||||
"functional_benefics": by_role["benefic"],
|
||||
"functional_malefics": by_role["malefic"],
|
||||
"functional_neutrals": by_role["neutral"],
|
||||
"yogakarakas": yogakarakas,
|
||||
"owned_houses": {planet: houses for planet, houses in sorted(owned_houses.items())},
|
||||
"effect_on_confidence": (
|
||||
"高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;"
|
||||
"若功能属性与自然属性冲突,应降低置信度或显式标记冲突。"
|
||||
),
|
||||
"source": SOURCE,
|
||||
"profile": PROFILE,
|
||||
"profile": PROFILE_IDS[functional_profile],
|
||||
"profile_source": profile_source,
|
||||
"lagna_lord": SIGN_LORDS[asc_sign],
|
||||
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
|
||||
"rule_reference": "references/functional-role-profile.md",
|
||||
"profile_is_not": PVR_TABLE30_PROFILE,
|
||||
"profile_is_not": PROFILE_IDS[other],
|
||||
"assertion_label": "parameter_sensitive",
|
||||
"ambiguous_cell_notes": {
|
||||
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en}
|
||||
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
|
||||
},
|
||||
"role_basis": {
|
||||
planet: "BPHS ch34 sign-specific exception" if planet in ROLE_OVERRIDES.get(asc_sign, {})
|
||||
else "BPHS ch34 general ownership; associations not evaluated"
|
||||
for planet in PLANETS
|
||||
"dual_lordship_note": DUAL_LORDSHIP_NOTE,
|
||||
"dual_lordship_note_en": DUAL_LORDSHIP_NOTE_EN,
|
||||
"role_basis": role_basis,
|
||||
# Both profiles side by side where they differ; the other profile never scores here.
|
||||
"profile_divergence": {
|
||||
"status": "differs" if divergence else "same",
|
||||
"selected_profile": functional_profile,
|
||||
"other_profile": other,
|
||||
"other_profile_scored": False,
|
||||
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
|
||||
"cells": divergence,
|
||||
"other_profile_roles": {
|
||||
"functional_benefics": other_by_role["benefic"],
|
||||
"functional_malefics": other_by_role["malefic"],
|
||||
"functional_neutrals": other_by_role["neutral"],
|
||||
"yogakarakas": other_yogakarakas,
|
||||
},
|
||||
},
|
||||
"prior_rulings": prior,
|
||||
"conditions": [
|
||||
"Neutral/mixed is association-dependent, not an absence of risk.",
|
||||
"Natural nature, strength, maraka roles and dasha activation remain separate.",
|
||||
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
|
||||
"Dual lordship needs an integrated judgment of both houses (PVR §13.2).",
|
||||
],
|
||||
}
|
||||
if moon_note or other_moon_note:
|
||||
result["movable_lagna_moon"] = moon_note or other_moon_note
|
||||
if functional_profile == FUNCTIONAL_PROFILE_BPHS:
|
||||
result["ambiguous_cell_notes"] = {
|
||||
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en}
|
||||
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
|
||||
}
|
||||
else:
|
||||
# #166 Q4 cell notes belong to the product table; under Table 30 they stay visible as notes on
|
||||
# the other profile, with the Table 30 role beside them.
|
||||
result["ambiguous_cell_notes"] = {
|
||||
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en,
|
||||
"applies_to_profile": FUNCTIONAL_PROFILE_BPHS, "pvr_table_30_role": roles.get(planet)}
|
||||
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
|
||||
}
|
||||
return result
|
||||
|
||||
+56
-41
@@ -1,41 +1,56 @@
|
||||
"""One functional benefic/malefic snapshot for the engine, API, and MCP.
|
||||
|
||||
This module is not ``functional_benefics.py``. That file is part of the
|
||||
rectification freeze and must stay byte-for-byte unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def functional_layer_for_ascendant(ascendant: Any) -> dict[str, Any]:
|
||||
"""Return the functional layer for an ascendant payload.
|
||||
|
||||
A dict uses its ``sign``. Anything else is a missing sign and follows
|
||||
the normal ``derive_functional_benefic_malefic`` blocked result.
|
||||
Import or calculation failures return the engine's blocked shape.
|
||||
The exception text stays inside ``effect_on_confidence`` because that
|
||||
field already did; callers must not write this dict to a log.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
from functional_benefics import derive_functional_benefic_malefic
|
||||
except ModuleNotFoundError:
|
||||
from scripts.functional_benefics import derive_functional_benefic_malefic
|
||||
|
||||
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
return derive_functional_benefic_malefic(sign)
|
||||
except Exception as exc:
|
||||
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
return {
|
||||
"status": "blocked",
|
||||
"ascendant": sign,
|
||||
"functional_benefics": [],
|
||||
"functional_malefics": [],
|
||||
"functional_neutrals": [],
|
||||
"yogakarakas": [],
|
||||
"owned_houses": {},
|
||||
"effect_on_confidence": f"未完成功能性吉凶星判定,需降低高严谨结论置信度: {exc}",
|
||||
"source": "strict_functional_benefic_malefic_v2",
|
||||
}
|
||||
"""One functional benefic/malefic snapshot for the engine, API, and MCP.
|
||||
|
||||
This module is not ``functional_benefics.py``. That file is in the
|
||||
rectification identity; rectification scoring names ``bphs_exception_profile``
|
||||
explicitly, every product surface takes the default ``pvr_table_30``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def functional_layer_for_ascendant(
|
||||
ascendant: Any,
|
||||
planets: Any = None,
|
||||
*,
|
||||
functional_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the functional layer for an ascendant payload.
|
||||
|
||||
A dict uses its ``sign``. Anything else is a missing sign and follows
|
||||
the normal ``derive_functional_benefic_malefic`` blocked result.
|
||||
Import or calculation failures return the engine's blocked shape.
|
||||
The exception text stays inside ``effect_on_confidence`` because that
|
||||
field already did; callers must not write this dict to a log.
|
||||
|
||||
占星师 2026-11-08 第五轮裁定 3: the default profile is PVR Table 30
|
||||
(``functional_benefics.DEFAULT_FUNCTIONAL_PROFILE``). ``planets`` (Sun and
|
||||
Moon longitudes) gives the Moon's paksha, which Table 30's note needs for
|
||||
Aries / Libra / Capricorn lagnas.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
import functional_benefics as fb
|
||||
except ModuleNotFoundError:
|
||||
from scripts import functional_benefics as fb
|
||||
|
||||
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
return fb.derive_functional_benefic_malefic(
|
||||
sign,
|
||||
functional_profile=functional_profile or fb.DEFAULT_FUNCTIONAL_PROFILE,
|
||||
moon_paksha=fb.moon_paksha_from_planets(planets),
|
||||
)
|
||||
except Exception as exc:
|
||||
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
return {
|
||||
"status": "blocked",
|
||||
"ascendant": sign,
|
||||
"functional_benefics": [],
|
||||
"functional_malefics": [],
|
||||
"functional_neutrals": [],
|
||||
"yogakarakas": [],
|
||||
"owned_houses": {},
|
||||
"effect_on_confidence": f"未完成功能性吉凶星判定,需降低高严谨结论置信度: {exc}",
|
||||
"source": "strict_functional_benefic_malefic_v2",
|
||||
}
|
||||
|
||||
@@ -1414,7 +1414,17 @@ def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None:
|
||||
# truncates the parent span to whole days and stays with rectification scoring.
|
||||
from scripts.chart_dasha import birth_moment
|
||||
|
||||
sub_periods = birth_moment.antardashas(current)
|
||||
# 占星师 2026-11-08 第五轮裁定 5: the balance mahadasha is cut from its full parent start and only
|
||||
# then clipped at birth; boundaries never move, the clipped row says where its parent row started.
|
||||
birth_info = chart.get('birth') if isinstance(chart.get('birth'), dict) else {}
|
||||
try:
|
||||
birth_dt = datetime.strptime(str(birth_info['date'])[:10], '%Y-%m-%d').replace(
|
||||
hour=int(birth_info['hour']), minute=int(birth_info['minute']), second=int(birth_info.get('second') or 0),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
birth_dt = None
|
||||
visible_from = birth_dt if birth_dt and current['start'] < birth_dt < current['end'] else None
|
||||
sub_periods = birth_moment.antardashas(current, visible_from=visible_from)
|
||||
if not sub_periods:
|
||||
return None
|
||||
current_sub = next(
|
||||
@@ -1427,11 +1437,16 @@ def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None:
|
||||
)
|
||||
|
||||
def boundary(period: dict) -> dict:
|
||||
return {
|
||||
row = {
|
||||
'lord': period['lord'],
|
||||
'start': period['start'].strftime('%Y-%m-%d'),
|
||||
'end': period['end'].strftime('%Y-%m-%d'),
|
||||
}
|
||||
parent = period.get('parent_period_start')
|
||||
if isinstance(parent, datetime) and parent != period['start']:
|
||||
row['parent_period_start'] = parent.strftime('%Y-%m-%d')
|
||||
row['visible_period_start'] = period['start'].strftime('%Y-%m-%d')
|
||||
return row
|
||||
|
||||
# BUG-1643: a ten-year question reaches past the running mahadasha, so the next one is cut the
|
||||
# same way (same periods, same antardasha cut); nothing else in this layer changes.
|
||||
@@ -6108,6 +6123,9 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
moon_lon,
|
||||
context=build_shadbala_context(jd, lat, lon, ayanamsa_name, tz),
|
||||
)
|
||||
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices.
|
||||
from shadbala_service.sensitivity import attach_parameter_sensitivity
|
||||
attach_parameter_sensitivity(sb, planets=shadbala_planets)
|
||||
# BUG-1208 (甲13): BPHS minimum first, then the site band, labelled.
|
||||
from shadbala_display import row_fields
|
||||
shadbala_summary = {p: {'rupas': round(d['total_rupas'],2), **row_fields(d)}
|
||||
@@ -6658,7 +6676,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
|
||||
def _functional_benefic_malefic_snapshot(self, planets, ascendant):
|
||||
from functional_layer import functional_layer_for_ascendant
|
||||
return functional_layer_for_ascendant(ascendant)
|
||||
return functional_layer_for_ascendant(ascendant, planets)
|
||||
|
||||
def _detect_yogas(self, planets, asc_idx):
|
||||
yogas = []
|
||||
|
||||
@@ -255,7 +255,8 @@ from cmd_solar_return import cmd_solar_return # v6.0.18
|
||||
from cmd_narayana_dasha import cmd_narayana_dasha as _cmd_narayana_dasha_impl # v6.0.20
|
||||
from cmd_muhurta import cmd_muhurta # v6.0.21
|
||||
from yoga_engine import detect_yogas, detect_yogas_with_status # v6.0.26: data-driven Yoga engine
|
||||
from shadbala_display import check_text as _shadbala_check_text, row_fields as _shadbala_row_fields # BUG-1208
|
||||
from shadbala_display import row_fields as _shadbala_row_fields # BUG-1208
|
||||
from shadbala_display import row_check_text as _shadbala_row_check_text # 第五轮裁定 4
|
||||
from kp_system import calc_kp_analysis, get_kp_lords # v6.9.10: KP完整系统
|
||||
from bhava_chalit import BhavaChalitCalculator, cmd_bhava_chalit # v6.9.13: Bhava Chalit 不等宫边界调整
|
||||
from sudarshana_chakra import calc_sudarshana_chakra, generate_sudarshana_report # v6.9.14: Sudarshana Chakra 三参考点盘
|
||||
@@ -10779,7 +10780,7 @@ def is_combust(planet_name, longitude, sun_longitude, retrograde=False):
|
||||
|
||||
def _functional_benefic_malefic_snapshot(planets, ascendant):
|
||||
from functional_layer import functional_layer_for_ascendant
|
||||
return functional_layer_for_ascendant(ascendant)
|
||||
return functional_layer_for_ascendant(ascendant, planets)
|
||||
|
||||
|
||||
def _build_technique_audit_table(functional_layer, oracle_progress, modules):
|
||||
@@ -11356,9 +11357,10 @@ def _build_health_narrative_payload(modules, chart=None):
|
||||
minimum = row.get('min_required')
|
||||
level = row.get('strength_level') or row.get('level')
|
||||
# BUG-1208 (甲13): the BPHS minimum check first, the band marked as the site's.
|
||||
check = _shadbala_check_text(_shadbala_row_fields(row)['meets_minimum'])
|
||||
# 占星师 2026-11-08 第五轮裁定 4: a flip under open parameters is parameter_sensitive, no verdict.
|
||||
check = _shadbala_row_check_text(row)
|
||||
strengths.append(f'{lord} Shadbala:total={total if total is not None else "-"},minimum={minimum if minimum is not None else "-"},{check},网站分档={level or "-"}。')
|
||||
if isinstance(total, (int, float)) and isinstance(minimum, (int, float)) and total < minimum:
|
||||
if row.get('minimum_check_status') != 'parameter_sensitive' and isinstance(total, (int, float)) and isinstance(minimum, (int, float)) and total < minimum:
|
||||
risks.append(f'{lord} 的 Shadbala 低于本地最低参考值,只能作为压力负荷提示。')
|
||||
|
||||
available = [f'D{number}' for number in (6, 8, 30) if division(number)]
|
||||
@@ -11878,6 +11880,8 @@ def _build_ai_prompt_pack(report):
|
||||
'min_required': pdata.get('min_required'),
|
||||
# BUG-1208 (甲13): BPHS minimum first; the band is the site's own.
|
||||
'meets_minimum': _shadbala_row_fields(pdata)['meets_minimum'],
|
||||
# 占星师 2026-11-08 第五轮裁定 4: parameter_sensitive when open parameters flip it.
|
||||
'minimum_check_status': pdata.get('minimum_check_status'),
|
||||
'strength_level': pdata.get('strength_level'),
|
||||
'level_source': 'site_band',
|
||||
})
|
||||
@@ -12486,6 +12490,9 @@ def cmd_dasha(args):
|
||||
"elapsed_at_birth": round(elapsed, 2) if i == 0 else None,
|
||||
# BUG-1675: where a table shows the balance years, it starts at birth.
|
||||
"visible_start": birth_dt.strftime("%Y-%m-%d") if i == 0 else None,
|
||||
# 占星师 2026-11-08 第五轮裁定 5: full parent start and the visible start after birth.
|
||||
"parent_period_start": dt.isoformat(timespec="seconds"),
|
||||
"visible_period_start": (max(dt, birth_dt) if i == 0 else dt).isoformat(timespec="seconds"),
|
||||
})
|
||||
dt = end_dt
|
||||
|
||||
@@ -12509,6 +12516,8 @@ def cmd_dasha(args):
|
||||
"balance_years": None,
|
||||
"elapsed_at_birth": None,
|
||||
"cycle": row["cycle"],
|
||||
"parent_period_start": row["start"].isoformat(timespec="seconds"),
|
||||
"visible_period_start": row["start"].isoformat(timespec="seconds"),
|
||||
})
|
||||
current = None
|
||||
for d in timeline:
|
||||
@@ -12534,6 +12543,10 @@ def cmd_dasha(args):
|
||||
child_end = datetime.fromisoformat(child["end_datetime"])
|
||||
if child_end <= visible_origin:
|
||||
continue
|
||||
# 占星师 2026-11-08 第五轮裁定 5: the boundary from the full parent stays; clipping
|
||||
# only moves the visible start.
|
||||
child["parent_period_start"] = child_start.isoformat(timespec="seconds")
|
||||
child["visible_period_start"] = max(child_start, visible_origin).isoformat(timespec="seconds")
|
||||
if child_start < visible_origin:
|
||||
child["unclipped_start_datetime"] = child_start.isoformat(timespec="seconds")
|
||||
child["start"] = visible_origin.strftime("%Y-%m-%d")
|
||||
@@ -12547,6 +12560,9 @@ def cmd_dasha(args):
|
||||
"end": se.strftime("%Y-%m-%d"),
|
||||
"start_datetime": visible_start.isoformat(timespec="seconds"),
|
||||
"unclipped_start_datetime": sdt.isoformat(timespec="seconds"),
|
||||
# 占星师 2026-11-08 第五轮裁定 5 (same values as unclipped_start_datetime / start_datetime).
|
||||
"parent_period_start": sdt.isoformat(timespec="seconds"),
|
||||
"visible_period_start": visible_start.isoformat(timespec="seconds"),
|
||||
"end_datetime": se.isoformat(timespec="seconds"),
|
||||
"is_current": visible_start <= today < se,
|
||||
"pratyantar_dasha_timeline": visible_children,
|
||||
@@ -13875,6 +13891,9 @@ def cmd_shadbala(args):
|
||||
# 交接分支 99076dad 的 required_strength_pct(sidecar;shadbala.py 冻结)。
|
||||
from shadbala_display import attach_required_strength_alias
|
||||
attach_required_strength_alias(result)
|
||||
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
|
||||
from shadbala_service.sensitivity import attach_parameter_sensitivity
|
||||
attach_parameter_sensitivity(result, planets=planets)
|
||||
# BUG-1699: upstream Chesta profile candidates (sidecar; shadbala.py is frozen).
|
||||
from shadbala_chesta_profiles import attach_chesta_profiles
|
||||
return attach_chesta_profiles(result, planets=planets, context=context,
|
||||
@@ -14898,6 +14917,8 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'):
|
||||
natal_moon_longitude=sr.get('birth_moon_longitude'),
|
||||
# BUG-1722: view clipped to this actual return -> next actual return; raw kept whole.
|
||||
coverage_end=next_return_local(sr, int(args.year) + int(age), sr_cache['ayanamsa']),
|
||||
# 第五轮裁定 2: actual-elapsed-time balance as a separate comparison.
|
||||
birth_jd_ut=sr.get('birth_jd_ut'), ayanamsa_name=sr_cache['ayanamsa'],
|
||||
)
|
||||
|
||||
result = {}
|
||||
@@ -16814,6 +16835,9 @@ def cmd_full_reading(args):
|
||||
# 交接分支 99076dad 的 required_strength_pct(sidecar;shadbala.py 冻结)。
|
||||
from shadbala_display import attach_required_strength_alias
|
||||
attach_required_strength_alias(shadbala_result)
|
||||
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
|
||||
from shadbala_service.sensitivity import attach_parameter_sensitivity
|
||||
attach_parameter_sensitivity(shadbala_result, planets=planets)
|
||||
# BUG-1699: upstream Chesta profile candidates (sidecar; shadbala.py is frozen).
|
||||
from shadbala_chesta_profiles import attach_chesta_profiles
|
||||
attach_chesta_profiles(shadbala_result, planets=planets, context=context,
|
||||
|
||||
+19
-5
@@ -654,12 +654,21 @@ def calc_kp_dba_timeline(
|
||||
target_end: datetime,
|
||||
planet_house_significators: Optional[Dict[str, Dict]] = None,
|
||||
) -> Dict:
|
||||
"""Build Vimshottari MD/AD/PD windows for KP-style marriage timing review."""
|
||||
"""Build Vimshottari MD/AD/PD windows for KP-style marriage timing review.
|
||||
|
||||
占星师 2026-11-08 第五轮裁定 10: the first mahadasha is generated whole (it starts
|
||||
``elapsed`` years before birth) and only then clipped at birth. 原值:首段 MD 按余额比例
|
||||
缩放成 balance × 全长,再在缩放后的 MD 里按比例切 AD / PD,父段、子段与后续边界全部改变。
|
||||
新值:完整父段起点 = 出生 − 已过部分;AD / PD 从完整父段切;出生前结束的行丢掉,跨出生的行
|
||||
可见起点裁到出生,``parent_period_start`` 留原始起点。原因:与 Vimshottari 同一原则。
|
||||
"""
|
||||
birth_star_lord, balance = _kp_birth_star_balance(moon_longitude)
|
||||
periods = []
|
||||
md_start = birth_datetime
|
||||
first_years = VIMSHOTTARI_YEARS[birth_star_lord]
|
||||
md_start = birth_datetime - timedelta(days=_kp_years_to_days(first_years * (1.0 - balance)))
|
||||
first_md_start = md_start
|
||||
for md_i, md_lord in enumerate(_kp_next_lords(birth_star_lord) * 3):
|
||||
md_years = VIMSHOTTARI_YEARS[md_lord] * (balance if md_i == 0 else 1.0)
|
||||
md_years = VIMSHOTTARI_YEARS[md_lord]
|
||||
md_end = md_start + timedelta(days=_kp_years_to_days(md_years))
|
||||
ad_start = md_start
|
||||
for ad_lord in _kp_next_lords(md_lord):
|
||||
@@ -669,14 +678,17 @@ def calc_kp_dba_timeline(
|
||||
for pd_lord in _kp_next_lords(ad_lord):
|
||||
pd_years = ad_years * VIMSHOTTARI_YEARS[pd_lord] / 120.0
|
||||
pd_end = pd_start + timedelta(days=_kp_years_to_days(pd_years))
|
||||
if pd_end >= target_start and pd_start <= target_end:
|
||||
visible_start = max(pd_start, birth_datetime)
|
||||
if pd_end > birth_datetime and pd_end >= target_start and visible_start <= target_end:
|
||||
scored = _kp_period_score([md_lord, ad_lord, pd_lord], planet_house_significators)
|
||||
periods.append({
|
||||
'md_lord': md_lord,
|
||||
'ad_lord': ad_lord,
|
||||
'pd_lord': pd_lord,
|
||||
'start': pd_start.isoformat(),
|
||||
'start': visible_start.isoformat(),
|
||||
'end': pd_end.isoformat(),
|
||||
'parent_period_start': pd_start.isoformat(),
|
||||
'visible_period_start': visible_start.isoformat(),
|
||||
**scored,
|
||||
})
|
||||
pd_start = pd_end
|
||||
@@ -688,6 +700,8 @@ def calc_kp_dba_timeline(
|
||||
'method': 'KP DBA timeline (Vimshottari MD/AD/PD)',
|
||||
'birth_star_lord': birth_star_lord,
|
||||
'birth_star_balance_fraction': balance,
|
||||
'first_parent_period_start': first_md_start.isoformat(),
|
||||
'first_segment_rule': 'full_parent_then_clip_at_birth',
|
||||
'target_start': target_start.isoformat(),
|
||||
'target_end': target_end.isoformat(),
|
||||
'periods': periods,
|
||||
|
||||
@@ -176,7 +176,7 @@ def _functional(chart: dict[str, Any]) -> dict[str, Any]:
|
||||
from functional_layer import functional_layer_for_ascendant
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from scripts.functional_layer import functional_layer_for_ascendant
|
||||
layer = functional_layer_for_ascendant(chart.get("ascendant"))
|
||||
layer = functional_layer_for_ascendant(chart.get("ascendant"), chart.get("planets"))
|
||||
return {
|
||||
"status": layer.get("status"),
|
||||
"benefics": list(layer.get("functional_benefics") or []),
|
||||
|
||||
@@ -1480,7 +1480,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
])
|
||||
# BUG-1208 (甲13): whether the BPHS minimum is met comes first; the
|
||||
# 150/125/100/75/50 band is the site's own and is labelled so.
|
||||
from shadbala_display import band_text as _band_text, check_text as _check_text, row_fields as _row_fields
|
||||
from shadbala_display import band_text as _band_text, row_check_text as _row_check_text
|
||||
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
|
||||
row = _dict(shadbala_planets.get(planet))
|
||||
if not row:
|
||||
@@ -1492,7 +1492,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
f"{_num(_dict(row.get('dig_bala')).get('total') or row.get('dig_bala'))} | {_num(_dict(row.get('kala_bala')).get('total') or row.get('kala_bala'))} | "
|
||||
f"{_num(_dict(row.get('chesta_bala')).get('total') or row.get('chesta_bala'))} | {_num(row.get('naisargika_bala'))} | "
|
||||
f"{_num(_dict(row.get('drik_bala')).get('total') or row.get('drik_bala'))} | {_num(total)} | {_num(required)} | "
|
||||
f"{_cell(_check_text(_row_fields(row)['meets_minimum'], _pl9_report_language(packet)))} | "
|
||||
f"{_cell(_row_check_text(row, _pl9_report_language(packet)))} | "
|
||||
f"{_cell(_band_text(row.get('strength_level'), _pl9_report_language(packet)))} |"
|
||||
)
|
||||
lines.append('')
|
||||
|
||||
@@ -12,7 +12,7 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from scripts.functional_benefics import derive_functional_benefic_malefic
|
||||
from scripts.functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
from scripts.narayana_dasha import narayana_dasha_full_report
|
||||
|
||||
|
||||
@@ -384,7 +384,9 @@ def replay_case(case: dict[str, Any], rule_version: str = "v1") -> dict[str, Any
|
||||
"matched_expected_label": False, "blocked_reason": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
|
||||
roles = derive_functional_benefic_malefic(chart["ascendant"]["sign"])
|
||||
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
|
||||
roles = derive_functional_benefic_malefic(chart["ascendant"]["sign"],
|
||||
moon_paksha=moon_paksha_from_planets(chart.get("planets")))
|
||||
md, ad = _find_dasha(dasha, event["event_date"])
|
||||
score = 0
|
||||
signals: list[str] = []
|
||||
|
||||
@@ -277,7 +277,9 @@ def score_event_with_policy(
|
||||
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
||||
target_lords = _house_lords(ascendant_index, target_houses)
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||||
natal_chart["ascendant"].get("sign")
|
||||
natal_chart["ascendant"].get("sign"),
|
||||
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
|
||||
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
|
||||
)
|
||||
functional_benefics_set = set(functional.get("functional_benefics") or [])
|
||||
functional_malefics_set = set(functional.get("functional_malefics") or [])
|
||||
|
||||
@@ -41,9 +41,26 @@ def minimum_fields(rupas: Any, min_required: Any, band: Any) -> Dict[str, Any]:
|
||||
return fields
|
||||
|
||||
|
||||
PARAMETER_SENSITIVE = 'parameter_sensitive'
|
||||
|
||||
|
||||
def row_fields(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Display fields for one `shadbala.calc_shadbala` planet row."""
|
||||
return minimum_fields(row.get('total_rupas', row.get('rupas')), row.get('min_required'), row.get('strength_level'))
|
||||
"""Display fields for one `shadbala.calc_shadbala` planet row.
|
||||
|
||||
占星师 2026-11-08 第五轮裁定 4: when `shadbala_service.sensitivity` found that the open
|
||||
parameter choices flip the planet's pass/fail, `minimum_check` is
|
||||
`parameter_sensitive` (the default verdict stays in `minimum_check_default`)
|
||||
and the open questions that flip it are listed.
|
||||
"""
|
||||
fields = minimum_fields(row.get('total_rupas', row.get('rupas')), row.get('min_required'), row.get('strength_level'))
|
||||
sensitivity = row.get('parameter_sensitivity') if isinstance(row.get('parameter_sensitivity'), dict) else {}
|
||||
if row.get('minimum_check_status') == PARAMETER_SENSITIVE and fields.get('minimum_check'):
|
||||
fields['minimum_check_default'] = fields['minimum_check']
|
||||
fields['minimum_check'] = PARAMETER_SENSITIVE
|
||||
fields['minimum_open_questions'] = list(sensitivity.get('open_questions') or [])
|
||||
elif row.get('minimum_check_status'):
|
||||
fields['minimum_check_status'] = row['minimum_check_status']
|
||||
return fields
|
||||
|
||||
|
||||
BAND_EN = {'极强': 'very strong', '强': 'strong', '充足': 'adequate', '略弱': 'slightly weak', '弱': 'weak', '极弱': 'very weak'}
|
||||
@@ -65,6 +82,17 @@ def check_text(meets: Optional[bool], language: str = 'zh') -> str:
|
||||
return MEETS_ZH if meets else MISSES_ZH
|
||||
|
||||
|
||||
SENSITIVE_ZH = 'parameter_sensitive(是否达到 BPHS 最低要求取决于未定 Shadbala 参数,不据此下好坏结论)'
|
||||
SENSITIVE_EN = 'parameter_sensitive (whether the BPHS minimum is met depends on open Shadbala parameters; no good/bad conclusion)'
|
||||
|
||||
|
||||
def row_check_text(row: Dict[str, Any], language: str = 'zh') -> str:
|
||||
"""`check_text` for a whole row: parameter_sensitive wins over the default verdict (第五轮裁定 4)."""
|
||||
if isinstance(row, dict) and row.get('minimum_check_status') == PARAMETER_SENSITIVE:
|
||||
return SENSITIVE_EN if language == 'en' else SENSITIVE_ZH
|
||||
return check_text(row_fields(row)['meets_minimum'] if isinstance(row, dict) else None, language)
|
||||
|
||||
|
||||
REQUIRED_STRENGTH_SEMANTICS = 'deprecated_alias_of_required_strength_pct_not_ishta_phala'
|
||||
|
||||
|
||||
|
||||
@@ -150,6 +150,12 @@ def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
|
||||
except ImportError: # pragma: no cover - package import path
|
||||
from scripts.shadbala_display import attach_required_strength_alias
|
||||
attach_required_strength_alias(result)
|
||||
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
|
||||
try:
|
||||
from shadbala_service.sensitivity import attach_parameter_sensitivity
|
||||
except ImportError: # pragma: no cover - package import path
|
||||
from scripts.shadbala_service.sensitivity import attach_parameter_sensitivity
|
||||
attach_parameter_sensitivity(result, planets=planets)
|
||||
# BUG-1699: upstream Chesta profile candidates / selection (sidecar; the
|
||||
# frozen shadbala.py is not touched). A requested profile is reported in
|
||||
# method_variants.chesta_profile_result next to the unchanged default.
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shadbala minimum check under the open parameter questions (占星师 2026-11-08 第五轮裁定 4).
|
||||
|
||||
The astrologer listed six Shadbala items that are not yet decided and ruled
|
||||
that until they are, a planet's "meets / misses the BPHS minimum" must not
|
||||
drive a good/bad conclusion when the open choices can flip it. This module
|
||||
runs the alternatives for real and marks only the planets whose pass/fail
|
||||
actually flips:
|
||||
|
||||
1. ``saptavargaja_moolatrikona``: the 45-virupa Moolatrikona slot. Default: D1
|
||||
inside the degree band only. Alternatives: D1 whole sign; the band in D1
|
||||
plus the whole Moolatrikona sign in the other six vargas; the whole sign in
|
||||
all seven vargas. Open question: the seven-varga composition and the
|
||||
Moolatrikona degree range.
|
||||
2. ``ayana_sun``: default A in Kala and Chesta = A (2A). Alternatives: the
|
||||
translator's doubled Sun in Kala (3A), doubled and copied to Chesta (4A).
|
||||
Open question: is the Sun's Ayana Bala counted twice.
|
||||
3. ``chesta_moon``: default Chesta = Paksha (2P in total). Alternative: no
|
||||
Chesta for the Moon (P). Raman doubles Paksha and leaves Chesta out, which
|
||||
is the same 2P as the default. Open question: is the Moon's Chesta her Paksha.
|
||||
4. ``drik_angle``: default continuous longitudes. Alternative: whole-sign
|
||||
(integer) aspect angles, i.e. the quarter / half / three-quarter / full
|
||||
aspects. Open question: continuous degrees or integer aspects.
|
||||
5. ``drik_special_aspects``: default the BPHS 26.9-12 special formulas replace
|
||||
the general segment. Alternative: general value plus Raman's fixed bonus.
|
||||
Open question: replace or add.
|
||||
6. ``drik_mercury_jupiter``: default every aspect at a quarter. Alternatives:
|
||||
Mercury / Jupiter at full value (1x), quarter plus full (1.25x for a
|
||||
benefic caster). Open question: 1, 1.25 or 1/4.
|
||||
|
||||
Every combination of the options is evaluated (the Sun and Moon items only
|
||||
touch those planets). ``shadbala.py`` is in the frozen rectification identity,
|
||||
so nothing there changes: the default totals stay, this module adds
|
||||
``parameter_sensitivity`` to each planet row and ``minimum_check_status``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from itertools import product
|
||||
from typing import Any, Dict
|
||||
|
||||
try:
|
||||
import shadbala as sb
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts import shadbala as sb
|
||||
|
||||
RULING = "占星师 2026-11-08 第五轮裁定 4"
|
||||
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
STATUS_SENSITIVE = "parameter_sensitive"
|
||||
STATUS_STABLE = "stable_across_open_parameters"
|
||||
STATUS_UNVERIFIED = "sensitivity_not_verified"
|
||||
|
||||
AXES = {
|
||||
"saptavargaja_moolatrikona": {
|
||||
"default": "d1_degree_band_only",
|
||||
"options": ("d1_degree_band_only", "d1_whole_sign", "d1_band_other_vargas_sign", "all_vargas_whole_sign"),
|
||||
"open_question": "Saptavargaja:七分盘组成与 Moolatrikona 度数范围(只 D1 度数段 / 整个星座 / 其余六分盘是否也计 45)",
|
||||
"planets": SEVEN,
|
||||
},
|
||||
"ayana_sun": {
|
||||
"default": "base_ayana_chesta_equals_ayana",
|
||||
"options": ("base_ayana_chesta_equals_ayana", "sun_doubled_in_kala", "sun_doubled_in_kala_and_chesta"),
|
||||
"open_question": "Ayana Bala:太阳是否重复计入(A+A / 2A+A / 2A+2A)",
|
||||
"planets": ("Sun",),
|
||||
},
|
||||
"chesta_moon": {
|
||||
"default": "moon_chesta_equals_paksha",
|
||||
"options": ("moon_chesta_equals_paksha", "moon_chesta_not_counted"),
|
||||
"open_question": "Chesta Bala:月亮是否等于 Paksha Bala(P+P / 只计一次 P)",
|
||||
"planets": ("Moon",),
|
||||
},
|
||||
"drik_angle": {
|
||||
"default": "continuous_degrees",
|
||||
"options": ("continuous_degrees", "whole_sign_integer_aspects"),
|
||||
"open_question": "Drik Bala:连续度数还是整数相位(整宫 1/4、1/2、3/4、全相)",
|
||||
"planets": SEVEN,
|
||||
},
|
||||
"drik_special_aspects": {
|
||||
"default": "replace_general_segment",
|
||||
"options": ("replace_general_segment", "add_to_general_value"),
|
||||
"open_question": "特殊相照:替换普通相照还是另加",
|
||||
"planets": SEVEN,
|
||||
},
|
||||
"drik_mercury_jupiter": {
|
||||
"default": "quarter",
|
||||
"options": ("quarter", "full_replaces_quarter", "quarter_plus_full"),
|
||||
"open_question": "水星、木星相照:1/4、1 倍还是 1.25 倍(1/4 + 全值)",
|
||||
"planets": SEVEN,
|
||||
},
|
||||
}
|
||||
AXIS_ORDER = tuple(AXES)
|
||||
CHAT_RULE = ("minimum_check = parameter_sensitive:达标与否随未定 Shadbala 参数翻转,"
|
||||
"聊天与报告不得据此下好坏结论,只写「是否达标取决于未定参数」并列出未定项")
|
||||
|
||||
|
||||
def _lon(name: str, planets: Dict) -> float:
|
||||
return sb._planet_longitude(name, planets)
|
||||
|
||||
|
||||
def _sapta(pname: str, planets: Dict, option: str, vargas: Dict[int, Dict[str, int]]) -> float:
|
||||
longitude = _lon(pname, planets)
|
||||
degree = longitude % 30.0
|
||||
mt_sign, mt_start, mt_end = sb._MOOLATRIKONA[pname]
|
||||
total = 0.0
|
||||
for division, positions in vargas.items():
|
||||
sign_idx = positions[pname]
|
||||
owner = sb.SIGN_LORDS[sb.SIGNS[sign_idx]]
|
||||
in_band = division == 1 and sign_idx == mt_sign and mt_start <= degree < mt_end
|
||||
if option == "d1_degree_band_only":
|
||||
mt = in_band
|
||||
elif option == "d1_whole_sign":
|
||||
mt = division == 1 and sign_idx == mt_sign
|
||||
elif option == "d1_band_other_vargas_sign":
|
||||
mt = in_band or (division != 1 and sign_idx == mt_sign)
|
||||
else: # all_vargas_whole_sign
|
||||
mt = sign_idx == mt_sign
|
||||
if mt:
|
||||
total += 45.0
|
||||
elif owner == pname:
|
||||
total += 30.0
|
||||
else:
|
||||
total += sb._compound_relation_score(pname, owner, vargas[1])
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def _drik(pname: str, planets: Dict, angle_mode: str, special: str, merc_jup: str) -> float:
|
||||
benefics, malefics = sb.classify_drik_planets(planets)
|
||||
target = _lon(pname, planets)
|
||||
total = Decimal(0)
|
||||
for other in sorted(benefics | malefics):
|
||||
if other == pname:
|
||||
continue
|
||||
if angle_mode == "whole_sign_integer_aspects":
|
||||
angle = ((int(target // 30) - int(_lon(other, planets) // 30)) % 12) * 30.0
|
||||
else:
|
||||
angle = target - _lon(other, planets)
|
||||
if special == "add_to_general_value":
|
||||
strength = sb._raman_sphuta_drishti_virupas(angle, other)
|
||||
else:
|
||||
strength = sb._sphuta_drishti_virupas(angle, other, "bphs")
|
||||
hundredths = Decimal(round(strength * 100))
|
||||
sign = 1 if other in benefics else -1
|
||||
quarter = hundredths * sign / 4
|
||||
if other in ("Mercury", "Jupiter") and merc_jup == "full_replaces_quarter":
|
||||
total += hundredths * sign
|
||||
elif other in ("Mercury", "Jupiter") and merc_jup == "quarter_plus_full":
|
||||
total += quarter + hundredths
|
||||
else:
|
||||
total += quarter
|
||||
return float((total / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def planet_sensitivity(pname: str, row: Dict[str, Any], planets: Dict) -> Dict[str, Any]:
|
||||
"""Pass/fail of one planet over every combination of the open options."""
|
||||
minimum = row.get("min_required")
|
||||
base_virupas = row.get("total_virupas")
|
||||
if not isinstance(minimum, (int, float)) or not isinstance(base_virupas, (int, float)):
|
||||
return {"status": STATUS_UNVERIFIED, "reason": "row_without_total_or_minimum"}
|
||||
sthana = row.get("sthana_bala") if isinstance(row.get("sthana_bala"), dict) else {}
|
||||
kala = row.get("kala_bala") if isinstance(row.get("kala_bala"), dict) else {}
|
||||
vargas = sb._saptavarga_signs(planets)
|
||||
# The default must be reproduced before any alternative is trusted.
|
||||
default_sapta = _sapta(pname, planets, "d1_degree_band_only", vargas)
|
||||
default_drik = _drik(pname, planets, "continuous_degrees", "replace_general_segment", "quarter")
|
||||
if abs(default_sapta - float(sthana.get("sapta_score", default_sapta))) > 0.011 or \
|
||||
abs(default_drik - float(row.get("drik_bala", default_drik))) > 0.011:
|
||||
return {"status": STATUS_UNVERIFIED, "reason": "default_components_not_reproduced",
|
||||
"reproduced": {"sapta_score": default_sapta, "drik_bala": default_drik}}
|
||||
|
||||
sapta_delta = {option: round(_sapta(pname, planets, option, vargas) - default_sapta, 2)
|
||||
for option in AXES["saptavargaja_moolatrikona"]["options"]}
|
||||
ayana = float(kala.get("base_ayana", kala.get("ayana", 0.0)) or 0.0)
|
||||
ayana_delta = {"base_ayana_chesta_equals_ayana": 0.0, "sun_doubled_in_kala": ayana,
|
||||
"sun_doubled_in_kala_and_chesta": 2 * ayana} if pname == "Sun" else {"base_ayana_chesta_equals_ayana": 0.0}
|
||||
paksha = float(row.get("chesta_bala", 0.0) or 0.0)
|
||||
chesta_delta = {"moon_chesta_equals_paksha": 0.0, "moon_chesta_not_counted": -paksha} if pname == "Moon" \
|
||||
else {"moon_chesta_equals_paksha": 0.0}
|
||||
drik_delta = {
|
||||
combo: round(_drik(pname, planets, *combo) - default_drik, 2)
|
||||
for combo in product(AXES["drik_angle"]["options"], AXES["drik_special_aspects"]["options"],
|
||||
AXES["drik_mercury_jupiter"]["options"])
|
||||
}
|
||||
|
||||
default_meets = base_virupas / sb.VIRUPAS_PER_RUPA >= minimum
|
||||
totals = []
|
||||
flip_sets: set[frozenset[str]] = set()
|
||||
for (sapta, s_d), (ayana_opt, a_d), (chesta_opt, c_d), (drik_combo, d_d) in product(
|
||||
sapta_delta.items(), ayana_delta.items(), chesta_delta.items(), drik_delta.items()):
|
||||
virupas = base_virupas + s_d + a_d + c_d + d_d
|
||||
rupas = virupas / sb.VIRUPAS_PER_RUPA
|
||||
totals.append(rupas)
|
||||
if (rupas >= minimum) != default_meets:
|
||||
chosen = {"saptavargaja_moolatrikona": sapta, "ayana_sun": ayana_opt, "chesta_moon": chesta_opt,
|
||||
"drik_angle": drik_combo[0], "drik_special_aspects": drik_combo[1],
|
||||
"drik_mercury_jupiter": drik_combo[2]}
|
||||
flip_sets.add(frozenset(axis for axis, value in chosen.items() if value != AXES[axis]["default"]))
|
||||
# Minimal flipping sets: moving these axes off their defaults flips the verdict and no subset
|
||||
# does (the full product contains every subset, so this is exact).
|
||||
minimal = sorted((sorted(item, key=AXIS_ORDER.index) for item in flip_sets
|
||||
if not any(other < item for other in flip_sets)), key=lambda row: (len(row), row))
|
||||
flipping_axes = {axis for row in minimal for axis in row}
|
||||
flips = bool(flip_sets)
|
||||
# Single-axis flips: each axis moved alone, the rest at their defaults.
|
||||
single = []
|
||||
for axis, deltas in (("saptavargaja_moolatrikona", sapta_delta), ("ayana_sun", ayana_delta),
|
||||
("chesta_moon", chesta_delta)):
|
||||
if any(((base_virupas + d) / sb.VIRUPAS_PER_RUPA >= minimum) != default_meets for d in deltas.values()):
|
||||
single.append(axis)
|
||||
for index, axis in enumerate(("drik_angle", "drik_special_aspects", "drik_mercury_jupiter")):
|
||||
for combo, d in drik_delta.items():
|
||||
others_default = all(combo[i] == AXES[name]["default"] for i, name in
|
||||
enumerate(("drik_angle", "drik_special_aspects", "drik_mercury_jupiter")) if i != index)
|
||||
if others_default and ((base_virupas + d) / sb.VIRUPAS_PER_RUPA >= minimum) != default_meets:
|
||||
single.append(axis)
|
||||
break
|
||||
return {
|
||||
"status": STATUS_SENSITIVE if flips else STATUS_STABLE,
|
||||
"default_meets_minimum": default_meets,
|
||||
"min_required": minimum,
|
||||
"default_rupas": round(base_virupas / sb.VIRUPAS_PER_RUPA, 4),
|
||||
"rupas_range": [round(min(totals), 4), round(max(totals), 4)],
|
||||
"combinations_evaluated": len(totals),
|
||||
"flips_with_axes": [axis for axis in AXIS_ORDER if axis in flipping_axes],
|
||||
"minimal_flip_sets": minimal,
|
||||
"flips_with_single_axis": [axis for axis in AXIS_ORDER if axis in single],
|
||||
"open_questions": [AXES[axis]["open_question"] for axis in AXIS_ORDER if axis in flipping_axes],
|
||||
"component_deltas_virupas": {
|
||||
"saptavargaja_moolatrikona": sapta_delta,
|
||||
**({"ayana_sun": ayana_delta} if pname == "Sun" else {}),
|
||||
**({"chesta_moon": chesta_delta} if pname == "Moon" else {}),
|
||||
"drik": {"|".join(combo): delta for combo, delta in drik_delta.items()},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def attach_parameter_sensitivity(result: Any, *, planets: Any) -> Any:
|
||||
"""Add the flip check to a ``shadbala.calc_shadbala`` result in place; default values unchanged."""
|
||||
if not isinstance(result, dict) or not isinstance(result.get("planets"), dict) or not isinstance(planets, dict):
|
||||
return result
|
||||
sensitive = []
|
||||
for pname, row in result["planets"].items():
|
||||
if pname not in SEVEN or not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
report = planet_sensitivity(pname, row, planets)
|
||||
except Exception as exc: # the default result must survive a failed check
|
||||
report = {"status": STATUS_UNVERIFIED, "reason": exc.__class__.__name__}
|
||||
row["parameter_sensitivity"] = report
|
||||
row["minimum_check_status"] = report["status"]
|
||||
if report["status"] == STATUS_SENSITIVE:
|
||||
sensitive.append(pname)
|
||||
result["minimum_check_sensitivity"] = {
|
||||
"ruling": RULING,
|
||||
"method": "every combination of the open options below; Sun / Moon items only for those planets",
|
||||
"axes": {axis: {"status": STATUS_SENSITIVE, "default": spec["default"], "options": list(spec["options"]),
|
||||
"open_question": spec["open_question"]} for axis, spec in AXES.items()},
|
||||
"planets_parameter_sensitive": sensitive,
|
||||
"chat_rule": CHAT_RULE,
|
||||
"defaults_unchanged": True,
|
||||
}
|
||||
variants = result.get("method_variants")
|
||||
if isinstance(variants, dict):
|
||||
variants["parameter_sensitive_items"] = {axis: AXES[axis]["open_question"] for axis in AXIS_ORDER}
|
||||
return result
|
||||
@@ -699,6 +699,8 @@ def solar_return_full_report(
|
||||
completed_years=age,
|
||||
natal_moon_longitude=sr.get('birth_moon_longitude'),
|
||||
coverage_end=next_return_local(sr, target_year, ayanamsa_name),
|
||||
# 第五轮裁定 2: actual-elapsed-time balance as a separate comparison.
|
||||
birth_jd_ut=sr.get('birth_jd_ut'), ayanamsa_name=ayanamsa_name,
|
||||
)
|
||||
except Exception as e:
|
||||
result['mudda_dasha'] = {'error': str(e)}
|
||||
|
||||
@@ -733,6 +733,18 @@ TAJIKA_FIVE_FOLD_LABEL = 'Tajika 五分力量'
|
||||
TAJIKA_FIVE_FOLD_LABEL_EN = 'Tajika five-fold strength'
|
||||
|
||||
|
||||
HARSHA_BLOCKED_UNTIL = (
|
||||
"harsha_four_component_definitions",
|
||||
"harsha_day_night_conditions",
|
||||
"combined_maximum_is_40",
|
||||
"20_plus_20_vs_15_plus_20_same_or_different_formula",
|
||||
)
|
||||
HARSHA_BLOCKED_REASON = (
|
||||
"Harsha 合分 blocked(占星师 2026-11-08 第五轮裁定 2):未批准 U 的 40 分或 R 的 35 分作默认合分;"
|
||||
"待确认①Harsha 四项分量的具体定义;②日间/夜间条件;③最大值是否为 40;④20+20 与 15+20 是否来自不同公式或不同尺度"
|
||||
)
|
||||
|
||||
|
||||
def calc_tajika_strength_layers(
|
||||
planet_lons: Dict[str, float],
|
||||
asc_lon: float = 0.0,
|
||||
@@ -790,6 +802,9 @@ def calc_tajika_strength_layers(
|
||||
row['status'] == 'usable' for row in panchavargiya_bala.values()
|
||||
)
|
||||
harsha_complete = all(row['status'] == 'complete' for row in harsha_bala.values())
|
||||
# 占星师 2026-11-08 第五轮裁定 2: neither U's 40 nor R's 35 is approved as the default combined
|
||||
# score; blocked until these are confirmed.
|
||||
blocked_until = list(HARSHA_BLOCKED_UNTIL)
|
||||
return {
|
||||
'status': 'partial',
|
||||
'method': 'Tajika Harsha/Panchavargiya Bala',
|
||||
@@ -808,10 +823,14 @@ def calc_tajika_strength_layers(
|
||||
for planet, data in harsha_bala.items()
|
||||
},
|
||||
'panchavargiya_bala': panchavargiya_bala,
|
||||
'combined_strength_blocked_until': blocked_until,
|
||||
'combined_strength_blocked_reason': HARSHA_BLOCKED_REASON,
|
||||
'combined_strength': {
|
||||
planet: {
|
||||
'status': 'blocked',
|
||||
'reason': 'combined_tajika_strength_not_ruled',
|
||||
'blocked_until': blocked_until,
|
||||
'reason_text': HARSHA_BLOCKED_REASON,
|
||||
'score': harsha_bala[planet]['score'],
|
||||
'max_score': harsha_bala[planet]['max_score'],
|
||||
'grade': 'blocked',
|
||||
|
||||
@@ -64,6 +64,86 @@ def _parse(value: str) -> datetime:
|
||||
return datetime.fromisoformat(str(value))
|
||||
|
||||
|
||||
# ── 占星师 2026-11-08 第五轮裁定 2:余额字段与名义年 ─────────────────────────────────
|
||||
NOMINAL_YEAR_DAYS_NOTE = ("Mudda 的名义年长;不等于两次真实太阳回归之间的间隔"
|
||||
"(实际约 365.2535–365.2608 天,名义年末提前约 302–930 秒)")
|
||||
BALANCE_METHOD_ANGLE = "nakshatra_arc_angle"
|
||||
BALANCE_METHOD_TIME = "actual_elapsed_time"
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
|
||||
|
||||
def balance_block(start_planet: Any, balance_moon_longitude: Any) -> Dict[str, Any]:
|
||||
"""Default traditional balance: the Moon's arc already covered in its nakshatra (angle based)."""
|
||||
try:
|
||||
lon = float(balance_moon_longitude) % 360.0
|
||||
except (TypeError, ValueError):
|
||||
return {"status": "blocked", "reason": "balance_moon_longitude_required", "balance_method": BALANCE_METHOD_ANGLE}
|
||||
elapsed = (lon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
|
||||
return {
|
||||
"status": "computed",
|
||||
"balance_method": BALANCE_METHOD_ANGLE,
|
||||
"is_default": True,
|
||||
"start_planet": start_planet,
|
||||
"elapsed_fraction": elapsed,
|
||||
"remaining_fraction": 1.0 - elapsed,
|
||||
"basis": "月亮在所在月宿内已走过的角度 ÷ 13°20′(默认传统算法)",
|
||||
}
|
||||
|
||||
|
||||
def time_balance_comparison(start_planet: Any, birth_jd_ut: Any, natal_moon_longitude: Any,
|
||||
ayanamsa_name: Any = None) -> Dict[str, Any]:
|
||||
"""Separate comparison: the share of the Moon's actual time in the nakshatra already elapsed at birth.
|
||||
|
||||
Never used for any date; never merged with the angle balance.
|
||||
"""
|
||||
base = {"balance_method": BALANCE_METHOD_TIME, "is_default": False, "used_for_dates": False,
|
||||
"start_planet": start_planet}
|
||||
try:
|
||||
import swisseph as swe
|
||||
try:
|
||||
from ayanamsa_utils import sidereal_flags
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.ayanamsa_utils import sidereal_flags
|
||||
jd0 = float(birth_jd_ut)
|
||||
natal = float(natal_moon_longitude) % 360.0
|
||||
except (ImportError, TypeError, ValueError):
|
||||
return {**base, "status": "blocked", "reason": "birth_jd_ut_and_swisseph_required"}
|
||||
flags = sidereal_flags(swe, ayanamsa_name)
|
||||
|
||||
def moon(jd: float) -> float:
|
||||
return float(swe.calc_ut(jd, swe.MOON, flags)[0][0]) % 360.0
|
||||
|
||||
offset = ((natal - moon(jd0) + 180.0) % 360.0) - 180.0
|
||||
if abs(offset) > 0.05: # the natal longitude is not in this frame
|
||||
return {**base, "status": "blocked", "reason": "natal_moon_frame_mismatch", "offset_degrees": round(offset, 4)}
|
||||
index = int(natal // NAKSHATRA_SPAN)
|
||||
|
||||
def crossing(boundary: float, lo: float, hi: float) -> float:
|
||||
def ahead(jd: float) -> bool:
|
||||
return (((moon(jd) + offset - boundary + 180.0) % 360.0) - 180.0) >= 0.0
|
||||
for _ in range(60):
|
||||
mid = (lo + hi) / 2.0
|
||||
if ahead(mid):
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
entry = crossing(index * NAKSHATRA_SPAN, jd0 - 2.0, jd0)
|
||||
exit_ = crossing((index + 1) * NAKSHATRA_SPAN, jd0, jd0 + 2.0)
|
||||
elapsed = (jd0 - entry) / (exit_ - entry)
|
||||
return {
|
||||
**base,
|
||||
"status": "computed",
|
||||
"elapsed_fraction": elapsed,
|
||||
"remaining_fraction": 1.0 - elapsed,
|
||||
"nakshatra_entry_jd_ut": round(entry, 6),
|
||||
"nakshatra_exit_jd_ut": round(exit_, 6),
|
||||
"basis": "出生时月亮在本月宿已停留的实际时间 ÷ 月亮走完本月宿的实际时间(独立对照,不进日期)",
|
||||
"difference_from_angle_fraction": None,
|
||||
}
|
||||
|
||||
|
||||
COVERAGE_BASIS = "actual_solar_return_to_next_actual_solar_return"
|
||||
COVERAGE_FALLBACK = "nominal_year_fallback_next_return_unknown"
|
||||
TIME_BASIS = "naive local wall time in the UTC offset of the solar-return instant"
|
||||
@@ -80,6 +160,7 @@ def coverage_fields(raw: Dict[str, Any], year_length: str, coverage_end: Optiona
|
||||
"nominal_year": {
|
||||
"basis": year_length,
|
||||
"days": float(raw["annual_duration_days"]),
|
||||
"nominal_year_days": float(raw["annual_duration_days"]),
|
||||
"start": raw["annual_start"],
|
||||
"end": raw["annual_end"],
|
||||
"note": "The dasha's own year length; not stretched to the next solar return.",
|
||||
@@ -112,6 +193,8 @@ def annual_mudda_dasha(
|
||||
natal_moon_longitude: Optional[float],
|
||||
year_length: str = DEFAULT_YEAR_LENGTH,
|
||||
coverage_end: Optional[datetime] = None,
|
||||
birth_jd_ut: Optional[float] = None,
|
||||
ayanamsa_name: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Mudda Dasha for one Varsha year, anchored at the solar-return instant.
|
||||
|
||||
@@ -136,6 +219,12 @@ def annual_mudda_dasha(
|
||||
year_days = float(raw["annual_duration_days"])
|
||||
coverage = coverage_fields(raw, year_length, coverage_end)
|
||||
periods, antardasha_periods = _crop(raw, *coverage.pop("_view_bounds"), year_days=year_days)
|
||||
balance = balance_block(raw.get("starting_lord"), natal_moon_longitude)
|
||||
comparison = time_balance_comparison(raw.get("starting_lord"), birth_jd_ut, natal_moon_longitude, ayanamsa_name) \
|
||||
if birth_jd_ut is not None else {"balance_method": BALANCE_METHOD_TIME, "is_default": False,
|
||||
"used_for_dates": False, "status": "blocked", "reason": "birth_jd_ut_not_passed"}
|
||||
if comparison.get("status") == "computed" and balance.get("status") == "computed":
|
||||
comparison["difference_from_angle_fraction"] = comparison["elapsed_fraction"] - balance["elapsed_fraction"]
|
||||
return {
|
||||
# The first-lord rule is the astrologer's round-5 ruling; dates are
|
||||
# not externally replayed (upstream keeps its own parameter_sensitive).
|
||||
@@ -155,6 +244,13 @@ def annual_mudda_dasha(
|
||||
"natal_moon_nakshatra_number": raw.get("natal_moon_nakshatra_number"),
|
||||
"balance_profile": raw.get("balance_profile"),
|
||||
"balance_fraction_remaining": raw.get("balance_fraction_remaining"),
|
||||
# 占星师 2026-11-08 第五轮裁定 2: angle balance is the default and drives the dates; the
|
||||
# actual-elapsed-time balance is a separate comparison, never mixed into one number.
|
||||
"balance": balance,
|
||||
"balance_method": balance.get("balance_method"),
|
||||
"balance_time_comparison": comparison,
|
||||
"nominal_year_days": year_days,
|
||||
"nominal_year_days_note": NOMINAL_YEAR_DAYS_NOTE,
|
||||
**coverage,
|
||||
"periods": periods,
|
||||
"antardasha_periods": antardasha_periods,
|
||||
@@ -276,6 +372,9 @@ def annual_mudda_profiles(
|
||||
"varsha_lord": raw.get("varsha_lord"),
|
||||
"balance_moon_longitude": raw.get("balance_moon_longitude"),
|
||||
"balance_fraction_remaining": raw.get("balance_fraction_remaining"),
|
||||
"balance": balance_block(raw.get("starting_lord") or first, raw.get("balance_moon_longitude"))
|
||||
if profile != "without_dasha_balance" else {"status": "not_applicable", "balance_method": "none"},
|
||||
"nominal_year_days": float(raw["annual_duration_days"]),
|
||||
"annual_start": raw.get("annual_start"),
|
||||
"annual_end": raw.get("annual_end"),
|
||||
"annual_duration_days": float(raw["annual_duration_days"]),
|
||||
|
||||
@@ -74,9 +74,9 @@ except Exception: # pragma: no cover - research helper is not vendored here
|
||||
return {"status": "blocked", "reason": "finance_astrology_support_review_absent"}
|
||||
|
||||
try:
|
||||
from functional_benefics import derive_functional_benefic_malefic
|
||||
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
from scripts.functional_benefics import derive_functional_benefic_malefic
|
||||
from scripts.functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
|
||||
try:
|
||||
from real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest
|
||||
@@ -947,7 +947,10 @@ class UnifiedConsultationOrchestrator:
|
||||
)
|
||||
ascendant = base_chart.get("ascendant") if isinstance(base_chart.get("ascendant"), dict) else {}
|
||||
ascendant_sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
functional_layer = derive_functional_benefic_malefic(ascendant_sign)
|
||||
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
|
||||
functional_layer = derive_functional_benefic_malefic(
|
||||
ascendant_sign, moon_paksha=moon_paksha_from_planets(base_chart.get("planets")),
|
||||
)
|
||||
official = vedastro_official if isinstance(vedastro_official, dict) else {}
|
||||
archive_manifest = vedastro_archive_manifest if isinstance(vedastro_archive_manifest, dict) else {}
|
||||
raw_response = (
|
||||
|
||||
@@ -363,6 +363,21 @@ def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local'
|
||||
'PVR Integrated Approach sections 6.2.5-6.2.11; D8 movable Aries, fixed Sagittarius, dual Leo'
|
||||
if div in (5, 6, 8, 11) else mapping_status
|
||||
)}}
|
||||
if div == 30 and mode == 'classical_local':
|
||||
# 原值: part_size = 30/30 = 1.0(D30 并非 30 等份,元数据误导)。新值: 同占星师 r40
|
||||
# varga.py:part_size=None,写明不等长五段(奇 5/5/8/7/5、偶 5/7/8/5/5)与落座星座。
|
||||
# 只改元数据,落座与座内度数不变。原因: 占星师 2026-11-08 第五轮裁定 13 第 4 步。
|
||||
vd['_meta'].update(
|
||||
part_size=None,
|
||||
part_sizes={'odd': [5.0, 5.0, 8.0, 7.0, 5.0], 'even': [5.0, 7.0, 8.0, 5.0, 5.0]},
|
||||
calculation_method='parashari_unequal_segment_linear_degrees_v1',
|
||||
degree_basis='linear_progress_within_unequal_segment',
|
||||
mapping_source='PVR Integrated Approach section 6.2.17',
|
||||
source_segments={
|
||||
'odd': [[0.0, 5.0, 0], [5.0, 10.0, 10], [10.0, 18.0, 8], [18.0, 25.0, 2], [25.0, 30.0, 6]],
|
||||
'even': [[0.0, 5.0, 1], [5.0, 12.0, 5], [12.0, 20.0, 11], [20.0, 25.0, 9], [25.0, 30.0, 7]],
|
||||
},
|
||||
)
|
||||
vd['Ascendant']=normalize_varga_ascendant(calc_varga(asc_lon,div,mode=mode))
|
||||
for pn,lon in planet_lons.items():
|
||||
vd[pn]=calc_varga(lon,div,mode=mode)
|
||||
|
||||
@@ -603,18 +603,21 @@ class YogaContext:
|
||||
def functional_malefics(self) -> List[str]:
|
||||
"""Ownership roles only; natural nature is a separate rule predicate."""
|
||||
try:
|
||||
from .functional_benefics import derive_functional_benefic_malefic
|
||||
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
except ImportError:
|
||||
from functional_benefics import derive_functional_benefic_malefic
|
||||
return derive_functional_benefic_malefic(self.ascendant)['functional_malefics']
|
||||
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30。
|
||||
return derive_functional_benefic_malefic(
|
||||
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_malefics']
|
||||
|
||||
def functional_benefics(self) -> List[str]:
|
||||
"""Use the same named ownership profile as the API and report."""
|
||||
try:
|
||||
from .functional_benefics import derive_functional_benefic_malefic
|
||||
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
except ImportError:
|
||||
from functional_benefics import derive_functional_benefic_malefic
|
||||
benefics = derive_functional_benefic_malefic(self.ascendant)['functional_benefics']
|
||||
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
|
||||
benefics = derive_functional_benefic_malefic(
|
||||
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_benefics']
|
||||
return [p for p in benefics if p in self.planets]
|
||||
|
||||
# --- Shashtiamsa / Vaiseshikamsa 简化评分 ---
|
||||
|
||||
@@ -136,7 +136,9 @@ def test_relationship_event_kinds_reuse_activation_without_fake_offsets(monkeypa
|
||||
monkeypatch.setattr(
|
||||
event_engine.functional_benefics,
|
||||
"derive_functional_benefic_malefic",
|
||||
lambda _sign: {"functional_benefics": [], "functional_malefics": []},
|
||||
# 原值:lambda _sign: ...;新值:接受 functional_profile 关键字(校正打分明确选中 bphs_exception_profile)
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 3(BUG-1746)
|
||||
lambda _sign, **_kw: {"functional_benefics": [], "functional_malefics": []},
|
||||
)
|
||||
common = {
|
||||
"candidate_time": "05:13",
|
||||
|
||||
@@ -75,8 +75,12 @@ def test_package_crossing_segment_uses_her_wording():
|
||||
data = tajika_mudda.annual_mudda_dasha(**KW, coverage_end=NEXT_RETURN)
|
||||
out = _crossing_segment(data, lambda value: _p(value).replace(tzinfo=zone), zone)
|
||||
assert out["lord"] == data["raw_periods"][-1]["lord"]
|
||||
assert out["text"].startswith("按上一年盘所用算法,该周期原始计算延伸至2027-02-27")
|
||||
assert "本年盘报告只解释至下一次太阳回归" in out["text"]
|
||||
# 原值:startswith("按上一年盘所用算法,该周期原始计算延伸至2027-02-27")
|
||||
# 新值:她的原话,原始延伸终点放括号里。原因:占星师 2026-11-08 第五轮裁定 2
|
||||
assert out["text"] == "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至2027-02-27)"
|
||||
# 原值:assert "本年盘报告只解释至下一次太阳回归" in out["text"];新值:删除(旧句已换成她的原话)
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 2
|
||||
assert "后续部分归入下一年盘解释" in out["text"]
|
||||
|
||||
|
||||
def test_calendar_year_spans_two_tajika_years_split_at_the_return():
|
||||
|
||||
@@ -29,7 +29,10 @@ from yoga_engine import YogaEngine # noqa: E402
|
||||
("Leo", "Jupiter", "benefic"), ("Capricorn", "Mercury", "benefic"),
|
||||
])
|
||||
def test_q4_ambiguous_cells(lagna: str, planet: str, role: str) -> None:
|
||||
result = functional_benefics.derive_functional_benefic_malefic(lagna)
|
||||
# 原值:derive_functional_benefic_malefic(lagna)(当时默认即产品表)。新值:明确选中 bphs_exception_profile,
|
||||
# 这些 #166 Q4 格是对产品表的裁定;默认档改为 PVR 表 30,双子水星在那里是中性(见 profile_divergence)。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 3
|
||||
result = functional_benefics.derive_functional_benefic_malefic(lagna, functional_profile="bphs_exception_profile")
|
||||
key = {"benefic": "functional_benefics", "malefic": "functional_malefics", "neutral": "functional_neutrals"}[role]
|
||||
assert planet in result[key]
|
||||
assert planet in result["ambiguous_cell_notes"]
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Astrologer round-5 rulings 2026-11-08, items 2, 3, 4, 5, 10, 13 (BUG-1746..BUG-1751).
|
||||
|
||||
Expected values are written out independently (PVR Table 30 transcribed from the book page,
|
||||
not read from the module). Public celebrity charts only (tests/celebrity_cases.json); no
|
||||
private birth data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
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))
|
||||
|
||||
import functional_benefics as fb # noqa: E402
|
||||
import kp_system # noqa: E402
|
||||
import tajika_mudda # noqa: E402
|
||||
import varga # noqa: E402
|
||||
|
||||
# PVR, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (PDF p. 179): B / N / M per lagna.
|
||||
TABLE_30 = {
|
||||
"Aries": ("Sun Mars Jupiter", "", "Mercury Venus Saturn"),
|
||||
"Taurus": ("Sun Mercury Saturn", "Mars", "Moon Jupiter Venus"),
|
||||
"Gemini": ("Venus", "Moon Mercury Saturn", "Sun Mars Jupiter"),
|
||||
"Cancer": ("Moon Mars Jupiter", "Sun Saturn", "Mercury Venus"),
|
||||
"Leo": ("Sun Mars Jupiter", "Moon", "Mercury Venus Saturn"),
|
||||
"Virgo": ("Mercury Venus", "Sun Saturn", "Moon Mars Jupiter"),
|
||||
"Libra": ("Mercury Venus Saturn", "", "Sun Mars Jupiter"),
|
||||
"Scorpio": ("Moon Jupiter", "Sun Mars", "Mercury Venus Saturn"),
|
||||
"Sagittarius": ("Sun Mars", "Moon Mercury Jupiter", "Venus Saturn"),
|
||||
"Capricorn": ("Venus Mercury Saturn", "Sun", "Mars Jupiter"),
|
||||
"Aquarius": ("Venus Saturn", "Sun Mercury", "Moon Mars Jupiter"),
|
||||
"Pisces": ("Moon Mars", "Jupiter", "Sun Mercury Venus Saturn"),
|
||||
}
|
||||
SIX_CELLS = {("Aries", "Mars"), ("Gemini", "Mercury"), ("Cancer", "Saturn"), ("Libra", "Venus"),
|
||||
("Scorpio", "Sun"), ("Pisces", "Jupiter")}
|
||||
|
||||
|
||||
# --- ruling 3: functional default = PVR Table 30 ---------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("sign", sorted(TABLE_30))
|
||||
def test_default_profile_is_table_30(sign: str) -> None:
|
||||
result = fb.derive_functional_benefic_malefic(sign)
|
||||
ben, neu, mal = (set(group.split()) for group in TABLE_30[sign])
|
||||
assert result["functional_profile"] == "pvr_table_30"
|
||||
assert set(result["functional_benefics"]) == ben
|
||||
assert set(result["functional_neutrals"]) == neu
|
||||
assert set(result["functional_malefics"]) == mal
|
||||
assert "PVR §13.2" in result["dual_lordship_note"]
|
||||
|
||||
|
||||
def test_six_table_cells_differ_and_both_roles_are_shown() -> None:
|
||||
cells = set()
|
||||
for sign in TABLE_30:
|
||||
divergence = fb.derive_functional_benefic_malefic(sign)["profile_divergence"]
|
||||
assert divergence["other_profile"] == "bphs_exception_profile" and divergence["other_profile_scored"] is False
|
||||
for cell in divergence["cells"]:
|
||||
if cell["kind"] == "table_cell":
|
||||
cells.add((sign, cell["planet"]))
|
||||
assert cell["pvr_table_30"] != cell["bphs_exception_profile"]
|
||||
assert cells == SIX_CELLS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("sign", "paksha", "role"), [
|
||||
("Aries", "waxing", "malefic"), ("Libra", "waning", "neutral"), ("Capricorn", "waning", "neutral"),
|
||||
("Capricorn", None, None),
|
||||
])
|
||||
def test_movable_lagna_moon_follows_the_table_note(sign, paksha, role) -> None:
|
||||
result = fb.derive_functional_benefic_malefic(sign, moon_paksha=paksha)
|
||||
moon = result["movable_lagna_moon"]
|
||||
assert moon["listed_in_table_30"] is False and moon["role"] == role
|
||||
lists = result["functional_benefics"] + result["functional_neutrals"] + result["functional_malefics"]
|
||||
assert ("Moon" in lists) == (role is not None)
|
||||
if role:
|
||||
assert "Moon" in result[f"functional_{role}s"]
|
||||
|
||||
|
||||
def test_bphs_exception_profile_scores_only_when_selected_and_prior_rulings_are_kept() -> None:
|
||||
bphs = fb.derive_functional_benefic_malefic("Gemini", functional_profile="bphs_exception_profile")
|
||||
assert "Mercury" in bphs["functional_benefics"]
|
||||
assert "Mercury" in fb.derive_functional_benefic_malefic("Gemini")["functional_neutrals"]
|
||||
prior = {row["planet"]: row for row in fb.derive_functional_benefic_malefic("Gemini")["prior_rulings"]}
|
||||
assert prior["Saturn"]["agrees"] is True # BUG-1590
|
||||
assert prior["Mercury"]["agrees"] is False # #166 Q4 note vs Table 30, resolved by the split
|
||||
for sign in ("Aries", "Scorpio"): # BUG-1570
|
||||
assert "Venus" in fb.derive_functional_benefic_malefic(sign)["functional_malefics"]
|
||||
assert fb.derive_functional_benefic_malefic("Leo", functional_profile="nope")["status"] == "blocked"
|
||||
|
||||
|
||||
def test_rectification_scoring_names_the_bphs_profile() -> None:
|
||||
for path in ("scripts/active_rectification_event_engine.py", "scripts/chart_choice/astrologer_engine.py",
|
||||
"scripts/research/precision_gate_lib.py"):
|
||||
source = (ROOT / path).read_text(encoding="utf-8")
|
||||
calls = source.count("derive_functional_benefic_malefic(")
|
||||
assert calls and source.count("functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS") == calls, path
|
||||
|
||||
|
||||
# --- ruling 4: Shadbala parameter_sensitive flip check -----------------------------------
|
||||
|
||||
def _shadbala(case_id: str) -> dict:
|
||||
data = json.loads((ROOT / "tests/celebrity_cases.json").read_text(encoding="utf-8"))
|
||||
cases = data.get("cases", data) if isinstance(data, dict) else data
|
||||
case = next(c for c in (cases.values() if isinstance(cases, dict) else cases) if c["id"] == case_id)
|
||||
args = [sys.executable, str(ROOT / "scripts/jyotish_engine.py"), "shadbala"]
|
||||
for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"):
|
||||
args += [f"--{key}", str(case[key])]
|
||||
out = json.loads(subprocess.run(args, capture_output=True, text=True, cwd=ROOT / "scripts", check=True).stdout)
|
||||
return out.get("result", out)
|
||||
|
||||
|
||||
def test_shadbala_flip_is_computed_not_labelled() -> None:
|
||||
result = _shadbala("obama")
|
||||
rows = result["planets"]
|
||||
# Jupiter: default below the minimum; Mercury/Jupiter aspect at 1x or 1.25x lifts it over.
|
||||
jupiter = rows["Jupiter"]["parameter_sensitivity"]
|
||||
assert jupiter["status"] == "parameter_sensitive" and jupiter["default_meets_minimum"] is False
|
||||
assert ["drik_mercury_jupiter"] in jupiter["minimal_flip_sets"]
|
||||
assert jupiter["rupas_range"][0] < rows["Jupiter"]["min_required"] <= jupiter["rupas_range"][1]
|
||||
# Only planets that actually flip are marked; the rest keep their verdict.
|
||||
stable = [name for name, row in rows.items() if row["minimum_check_status"] == "stable_across_open_parameters"]
|
||||
assert len(stable) == 6 and result["minimum_check_sensitivity"]["planets_parameter_sensitive"] == ["Jupiter"]
|
||||
assert set(result["minimum_check_sensitivity"]["axes"]) == {
|
||||
"saptavargaja_moolatrikona", "ayana_sun", "chesta_moon", "drik_angle", "drik_special_aspects",
|
||||
"drik_mercury_jupiter"}
|
||||
|
||||
|
||||
def test_display_turns_a_flipping_planet_into_parameter_sensitive() -> None:
|
||||
from shadbala_display import row_check_text, row_fields
|
||||
|
||||
row = {"total_rupas": 6.0, "min_required": 6.5, "strength_level": "略弱",
|
||||
"minimum_check_status": "parameter_sensitive", "parameter_sensitivity": {"open_questions": ["q"]}}
|
||||
fields = row_fields(row)
|
||||
assert fields["minimum_check"] == "parameter_sensitive" and fields["minimum_open_questions"] == ["q"]
|
||||
assert fields["minimum_check_default"] == "未达到 BPHS 最低要求"
|
||||
assert row_check_text(row).startswith("parameter_sensitive")
|
||||
contract = (ROOT / "references/product_runtime_contract.md").read_text(encoding="utf-8")
|
||||
assert "`minimum_check = parameter_sensitive` 的星,聊天与报告不得用「达标/不达标」下好坏结论" in contract
|
||||
|
||||
|
||||
# --- ruling 5: Vimshottari parent / visible start ----------------------------------------
|
||||
|
||||
def test_birth_moment_rows_keep_parent_start_when_clipped() -> None:
|
||||
from chart_dasha import birth_moment
|
||||
|
||||
md = {"lord": "Moon", "start": datetime(1955, 1, 1), "end": datetime(1965, 1, 1)}
|
||||
birth = datetime(1958, 6, 1)
|
||||
full = birth_moment.antardashas(md)
|
||||
clipped = birth_moment.antardashas(md, visible_from=birth)
|
||||
by_lord = {row["lord"]: row for row in full}
|
||||
for row in clipped:
|
||||
assert row["parent_period_start"] == by_lord[row["lord"]]["start"] # boundaries never move
|
||||
assert row["end"] == by_lord[row["lord"]]["end"]
|
||||
assert row["visible_period_start"] == max(row["parent_period_start"], birth)
|
||||
assert clipped[0]["visible_period_start"] == birth and clipped[0]["parent_period_start"] < birth
|
||||
|
||||
|
||||
# --- ruling 10: KP first segment ---------------------------------------------------------
|
||||
|
||||
def test_kp_first_segment_is_the_full_parent_clipped_at_birth() -> None:
|
||||
birth = datetime(1990, 1, 1, 12, 0)
|
||||
moon = 3 * (360 / 27) + (360 / 27) * 0.75 # Rohini (Moon), 75 % elapsed
|
||||
out = kp_system.calc_kp_dba_timeline(birth, moon, birth, birth + timedelta(days=365.2425 * 5))
|
||||
assert out["first_segment_rule"] == "full_parent_then_clip_at_birth"
|
||||
parent = datetime.fromisoformat(out["first_parent_period_start"])
|
||||
assert abs((birth - parent).total_seconds() / 86400 - 10 * 0.75 * 365.2425) < 1e-3
|
||||
first = out["periods"][0]
|
||||
# Moon MD, full 10 years: AD ends at 0.83, 1.42, 2.92, 4.25, 5.83, 7.25, 7.83 (Ketu) ... y; 7.5 y -> Ketu AD.
|
||||
assert first["md_lord"] == "Moon" and first["ad_lord"] == "Ketu"
|
||||
assert first["start"] == birth.isoformat() and datetime.fromisoformat(first["parent_period_start"]) < birth
|
||||
md_end = parent + timedelta(days=10 * 365.2425)
|
||||
assert any(datetime.fromisoformat(row["start"]) == md_end and row["md_lord"] == "Mars" for row in out["periods"])
|
||||
|
||||
|
||||
# --- ruling 2: Mudda balance, nominal year, wording, Harsha ------------------------------
|
||||
|
||||
def test_mudda_balance_fields_and_nominal_year() -> None:
|
||||
import swisseph as swe
|
||||
|
||||
out = tajika_mudda.annual_mudda_dasha(
|
||||
annual_start=datetime(2026, 2, 25, 0, 21, 4), completed_years=71, natal_moon_longitude=345.95793509035576,
|
||||
coverage_end=datetime(2027, 2, 25, 6, 29, 6), birth_jd_ut=swe.julday(1955, 2, 25, 3.25), ayanamsa_name="raman")
|
||||
balance, comparison = out["balance"], out["balance_time_comparison"]
|
||||
assert out["balance_method"] == "nakshatra_arc_angle" and balance["is_default"] is True
|
||||
assert balance["start_planet"] == out["starting_lord"]
|
||||
assert balance["remaining_fraction"] == pytest.approx(out["balance_fraction_remaining"])
|
||||
assert balance["elapsed_fraction"] + balance["remaining_fraction"] == pytest.approx(1.0)
|
||||
assert comparison["balance_method"] == "actual_elapsed_time" and comparison["used_for_dates"] is False
|
||||
assert comparison["status"] == "computed" and comparison["elapsed_fraction"] != balance["elapsed_fraction"]
|
||||
assert out["nominal_year_days"] == 365.25 and out["nominal_year"]["nominal_year_days"] == 365.25
|
||||
|
||||
|
||||
def test_harsha_combined_blocked_reason_names_the_open_items() -> None:
|
||||
from tajika import calc_tajika_strength_layers
|
||||
|
||||
lons = {"Sun": 10.0, "Moon": 50.0, "Mars": 100.0, "Mercury": 20.0, "Jupiter": 200.0, "Venus": 330.0, "Saturn": 280.0}
|
||||
out = calc_tajika_strength_layers(lons, 15.0, "Sun", is_daytime=True)
|
||||
assert out["combined_strength_blocked_until"] == [
|
||||
"harsha_four_component_definitions", "harsha_day_night_conditions", "combined_maximum_is_40",
|
||||
"20_plus_20_vs_15_plus_20_same_or_different_formula"]
|
||||
reason = out["combined_strength"]["Sun"]["reason_text"]
|
||||
for words in ("四项分量", "日间/夜间", "40", "20+20 与 15+20"):
|
||||
assert words in reason
|
||||
assert all(row["status"] == "blocked" for row in out["combined_strength"].values())
|
||||
|
||||
|
||||
# --- ruling 13 step 4: D30 metadata ------------------------------------------------------
|
||||
|
||||
def test_d30_meta_describes_unequal_parts() -> None:
|
||||
meta = varga.calc_all_vargas({"Sun": 12.0}, 0.0, [30])["D30_Trimsamsa"]["_meta"]
|
||||
assert meta["part_size"] is None
|
||||
assert meta["part_sizes"] == {"odd": [5.0, 5.0, 8.0, 7.0, 5.0], "even": [5.0, 7.0, 8.0, 5.0, 5.0]}
|
||||
# The described segments are the ones the mapping uses.
|
||||
for parity, sign_idx in (("odd", 0), ("even", 1)):
|
||||
for start, end, target in meta["source_segments"][parity]:
|
||||
assert varga.calc_varga(sign_idx * 30 + (start + end) / 2, 30)["sign_idx"] == target
|
||||
@@ -137,9 +137,10 @@ 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"
|
||||
# 原值 "bphs_ch34_general_with_sign_exceptions_v2";新值 "..._v3_166q4"(上游 v3 表 + #166 Q4 土星行)
|
||||
# 原因:三方对照 2026-11-07:产品侧实现缺陷——标签写 v2,实际表不是 v2
|
||||
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v3_166q4"
|
||||
# 原值 "bphs_ch34_general_with_sign_exceptions_v3_166q4";新值 "pvr_integrated_approach_13_2_table_30"
|
||||
# (默认 PVR 表 30,狮子上升两档相同)。原因:占星师 2026-11-08 第五轮裁定 3
|
||||
assert result["profile"] == "pvr_integrated_approach_13_2_table_30"
|
||||
assert result["functional_profile"] == "pvr_table_30" and result["profile_divergence"]["status"] == "same"
|
||||
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"]
|
||||
|
||||
@@ -23,7 +23,10 @@ def test_every_lagna_mirrors_the_profile(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"
|
||||
# 原值:不在吉 / 凶表里一律 "neutral"。新值:三表都不在(PVR 表 30 不列动星座上升的月亮、盈亏未给)为 "pending"。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30)
|
||||
expected = ("benefic" if planet in base["functional_benefics"] else "malefic" if planet in base["functional_malefics"]
|
||||
else "neutral" if planet in base["functional_neutrals"] else "pending")
|
||||
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"])
|
||||
|
||||
@@ -19,6 +19,11 @@ YOGAKARAKAS = {
|
||||
# 见 references/functional-role-profile.md 与 BUG-1158
|
||||
from scripts.functional_benefics import ROLE_OVERRIDES # noqa: E402
|
||||
|
||||
# 原值:derive_functional_benefic_malefic(sign)(当时的默认档就是这张产品表)。
|
||||
# 新值:本文件锁的是产品表,必须明确选中 functional_profile="bphs_exception_profile";默认档改为 PVR 表 30,
|
||||
# 见 tests/test_functional_pvr_default_20261108.py。原因:占星师 2026-11-08 第五轮裁定 3
|
||||
BPHS = "bphs_exception_profile"
|
||||
|
||||
LAGNA_LORDS = {
|
||||
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Virgo": "Mercury", "Libra": "Venus",
|
||||
"Scorpio": "Mars", "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter",
|
||||
@@ -27,7 +32,7 @@ LAGNA_LORDS = {
|
||||
|
||||
@pytest.mark.parametrize("sign", sorted(LAGNA_LORDS))
|
||||
def test_lagna_lord_role_follows_profile(sign: str) -> None:
|
||||
result = derive_functional_benefic_malefic(sign)
|
||||
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
|
||||
lord = LAGNA_LORDS[sign]
|
||||
expected = ROLE_OVERRIDES.get(sign, {}).get(lord, "benefic")
|
||||
assert lord in result[f"functional_{expected}s"], (sign, lord, expected)
|
||||
@@ -38,22 +43,22 @@ def test_lagna_lord_role_follows_profile(sign: str) -> None:
|
||||
|
||||
@pytest.mark.parametrize("sign", SIGNS)
|
||||
def test_sign_overrides_win(sign: str) -> None:
|
||||
result = derive_functional_benefic_malefic(sign)
|
||||
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
|
||||
for planet, role in ROLE_OVERRIDES.get(sign, {}).items():
|
||||
assert planet in result[f"functional_{role}s"], (sign, planet, role)
|
||||
|
||||
|
||||
def test_sole_twelfth_lord_is_not_automatically_malefic() -> None:
|
||||
# Virgo: the Sun owns only the 12th; Leo: the Moon owns only the 12th.
|
||||
assert "Sun" not in derive_functional_benefic_malefic("Virgo")["functional_malefics"]
|
||||
assert "Moon" not in derive_functional_benefic_malefic("Leo")["functional_malefics"]
|
||||
assert "Sun" not in derive_functional_benefic_malefic("Virgo", functional_profile=BPHS)["functional_malefics"]
|
||||
assert "Moon" not in derive_functional_benefic_malefic("Leo", functional_profile=BPHS)["functional_malefics"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("sign", "houses"), [("Aries", [2, 7]), ("Scorpio", [7, 12])])
|
||||
def test_venus_is_functional_malefic_for_aries_and_scorpio(sign: str, houses: list[int]) -> None:
|
||||
# BUG-1570(产品 2026-11-04 决定):BPHS 34.20「mandasaumyasitāḥ pāpāḥ」、34.35「sitajñaśanayaḥ pāpāḥ」
|
||||
# 点名金星(sita)为凶;逐上升明文优先于总则(只管角宫 / 2 宫 → 中性)。期望值独立写出,不读 ROLE_OVERRIDES。
|
||||
result = derive_functional_benefic_malefic(sign)
|
||||
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
|
||||
assert result["owned_houses"]["Venus"] == houses
|
||||
assert "Venus" in result["functional_malefics"]
|
||||
assert "Venus" not in result["functional_neutrals"] + result["functional_benefics"]
|
||||
|
||||
@@ -106,7 +106,11 @@ def test_birth_data_request_returns_precise_shadbala(api_base) -> None:
|
||||
}
|
||||
assert chesta_keys <= set(result["method_variants"])
|
||||
assert result["method_variants"]["chesta_profile_selection"]["effective_profile"] == "bphs_bounded_surya_mean_motion_seeghrochcha"
|
||||
assert {k: v for k, v in result["method_variants"].items() if k not in chesta_keys} == {
|
||||
# 原值:method_variants 只有下列具名口径。新值:另有 parameter_sensitive_items(六个未定项及其问题),单独核对。
|
||||
# 原因:占星师 2026-11-08 第五轮裁定 4
|
||||
assert set(result["method_variants"]["parameter_sensitive_items"]) == {
|
||||
"saptavargaja_moolatrikona", "ayana_sun", "chesta_moon", "drik_angle", "drik_special_aspects", "drik_mercury_jupiter"}
|
||||
assert {k: v for k, v in result["method_variants"].items() if k not in chesta_keys | {"parameter_sensitive_items"}} == {
|
||||
"kala": "bphs_local_solar_events_ahargana_declination",
|
||||
"chesta": "bphs_bounded_surya_mean_motion_seeghrochcha",
|
||||
# 原值: moon_paksha bphs_santhanam_27_10_11_chesta_27_18,无下列五项
|
||||
|
||||
@@ -56,15 +56,18 @@ def test_pvr_table30_is_a_separate_named_profile_and_virgo_matches_product():
|
||||
# 占星师 2026-11-07 Q7: Virgo Sun / Saturn are functional neutrals in PVR Table 30.
|
||||
from functional_benefics import derive_functional_benefic_malefic, pvr_table30_roles, PVR_TABLE30_PROFILE
|
||||
|
||||
product = derive_functional_benefic_malefic("Virgo")
|
||||
# 原值:derive_functional_benefic_malefic("Virgo") 是产品表,profile_is_not == PVR。新值:产品表须明确选中
|
||||
# bphs_exception_profile;默认档是 PVR 表 30。原因:占星师 2026-11-08 第五轮裁定 3
|
||||
product = derive_functional_benefic_malefic("Virgo", functional_profile="bphs_exception_profile")
|
||||
pvr = pvr_table30_roles("Virgo")
|
||||
assert {"Sun", "Saturn"} <= set(product["functional_neutrals"])
|
||||
assert pvr["functional_neutrals"] == ["Saturn", "Sun"]
|
||||
assert product["profile"] != PVR_TABLE30_PROFILE and product["profile_is_not"] == PVR_TABLE30_PROFILE
|
||||
assert derive_functional_benefic_malefic("Virgo")["profile"] == PVR_TABLE30_PROFILE
|
||||
differing = []
|
||||
for sign in ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius",
|
||||
"Capricorn", "Aquarius", "Pisces"):
|
||||
ours, ref = derive_functional_benefic_malefic(sign), pvr_table30_roles(sign)
|
||||
ours, ref = derive_functional_benefic_malefic(sign, functional_profile="bphs_exception_profile"), pvr_table30_roles(sign)
|
||||
for role in ("functional_benefics", "functional_neutrals", "functional_malefics"):
|
||||
for planet in set(ref[role]) - set(ours[role]):
|
||||
differing.append((sign, planet))
|
||||
|
||||
Reference in New Issue
Block a user