Files
Jyotisha/scripts/consultation_native_layers.py
jesse-uxandGrok (agent loop) 74a739b2dc feat(card): seven acceptance counterexamples end to end (BUG-1812)
- career layer gets the D1 longitudes, so the paksha has one source (Obama
  base vs career mismatch fixed)
- card carries movable_lagna_moon.reason, Narayana ad_status / ad_reason and
  the mahadasha cycle_index; running line says "sub-period undecided"
- engine keeps the MD when the AD is undecided; timingFollowing skips
  zero_duration rows explicitly
- timingKeys allowlist: adstatus / adreason / cycleindex / zeroduration
- fixtures + capture script, 16 engine tests, 7 card tests, PROGRESS, BUG-1812

Co-Authored-By: Grok (agent loop)
2026-11-09 08:38:56 +08:00

2449 lines
121 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Expose existing native technique modules in the web consultation output.
TASK-consult-evidence-card-v2-20260927 T1. The consultation workflow already
computes the chart, the formal vargas, Ashtakavarga, Arudha padas and the chara
karakas, but several techniques the repository implements never reached its
output: the D9 dignity layer and Vargottama, Punarphoo, Double Transit, the
Rahu/Ketu and slow-planet ingress and station dates, Vivah Saham, Karakamsha,
Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit and the
annual Tajika / Varshaphala pack.
This module only adds output. Every value comes from a native producer
(`jyotish_engine`, `punarphoo`, `ephemeris_events`, `jaimini`, `argala`,
`annual_tajika_pack`); what this module adds on top is selection, the
whole-sign house of a sign that a producer already returned, and the calendar
dates of the Mudda Dasha segments (see `_mudda_segments`). It does not change
any existing module, score, candidate or frozen scoring file, and it writes a
single new top-level chart key, `chart.consultation_native_layers`, which no
existing reader consumes. It is kept out of `chart.modules` on purpose: the
thematic report counts the modules it was handed (`full_reading_module_count`)
and that existing value must not move.
TASK-consult-card-affliction-data-20261001 adds three outputs: the whole-sign
graha drishti and combustion (`graha_drishti`, from `aspects.calc_house_aspects`
and the full reading's `jyotish_engine.is_combust`), the domain tag of each
sieved yoga (`yoga_domains`, a fixed packet table) and transit Saturn's house
from the natal Moon (`slow_transits.saturn_from_moon_house`).
产品 2026-11-07(占星师方案:回答模型拿到完整数据)adds the card layers:
`kp_placidus` (`jyotish_engine.cmd_kp`), `dasha_systems` (every dasha system
with an engine module: running MD / AD / PD and applicability),
`special_points` (`chart_view_points.api.compute_chart_points`),
`transit_detail` (Tara / Chandra Bala, Sade Sati), `yoga_conditions`,
`strength_detail` (Avastha, Bhava Bala) and `card_parameters`. A system no
module computes is listed as `not_available_yet`, never filled in.
The annual pack is attached only when the declared route is `annual`. When the
pack cannot be produced reliably it is reported as blocked with the reason;
natal data is never substituted for the annual chart.
"""
from __future__ import annotations
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
SCHEMA = "jyotish.consultation_native_layers.v1"
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
TRANSIT_WINDOW_DAYS = 365
MAX_DOUBLE_TRANSIT_CONCLUSIONS = 24
_STRONG_DIGNITIES = frozenset({"EXALTED", "MOOLATRIKONA", "OWN_SIGN"})
_WEAK_DIGNITIES = frozenset({"DEBILITATED", "NEECHA_BHANGA"})
REVERSAL_RULE = (
"D1 in EXALTED / MOOLATRIKONA / OWN_SIGN and D9 in DEBILITATED / NEECHA_BHANGA, or the reverse; "
"levels from jyotish_engine._get_dignity_level"
)
# Graha drishti casters (TASK-consult-card-affliction-data-20261001 D2): the
# seven grahas; Rahu / Ketu receive aspects but cast none here (schools
# differ), their influence reaches the card through house co-tenancy.
DRISHTI_CASTERS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
DRISHTI_RULE = (
"Parashari whole-sign graha drishti (aspects.calc_house_aspects): every graha the 7th; "
"Mars also 4 / 8, Jupiter 5 / 9, Saturn 3 / 10; Rahu / Ketu cast none"
)
# Domain tag of each Raman support packet (scripts/raman_support_observations.py).
# A fixed table: a yoga's domain is the packet that governs it, never a guess
# from its name (BUG-1157). The Nabhasa / Chandra batch describes the whole
# chart, so it is `general`.
YOGA_PACKET_DOMAINS = {
"raman_bvr_nabhasa_chandra_sunapha_batch_closure_packet_2026_08_20.json": "general",
"raman_bvr_wealth_core_batch_closure_packet_2026_08_20.json": "wealth",
"raman_bvr_relationship_spouse_batch_closure_packet_2026_08_20.json": "marriage",
"raman_bvr_relationship_children_batch_closure_packet_2026_08_20.json": "children",
"raman_bvr_relationship_mother_batch_closure_packet_2026_08_20.json": "parents",
"raman_bvr_relationship_sibling_core_batch_closure_packet_2026_08_20.json": "siblings",
}
# BUG-1214 (round-4 ruling Q3): the year lord is chosen by the Panchadhikari
# rule (tajika_year_lord); the card carries its one-sentence basis. Replaces
# the BUG-1212 stand-in label. Fallback text only if a producer gives none.
YEAR_LORD_BASIS_MISSING = "选法依据缺失"
TAJIKA_BOUNDARY = (
"parameter_sensitive: Tajika / Varshaphala is verified on the first target set only, "
"not a global closure; annual structure, not an event verdict"
)
def _scripts_on_path() -> None:
scripts_dir = str(Path(__file__).resolve().parent)
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
def _load_engine():
# Same loader as consultation_engine_field_bridge: reuse the engine module
# the consultation path already imported instead of loading another copy.
_scripts_on_path()
try:
from scripts import jyotish_engine as engine
except ModuleNotFoundError: # pragma: no cover - script execution
import jyotish_engine as engine
return engine
def _record(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _sign_index(sign: Any) -> int | None:
return SIGNS.index(sign) if sign in SIGNS else None
def _whole_sign_house(sign: Any, lagna_sign_idx: int | None) -> int | None:
idx = _sign_index(sign)
if idx is None or lagna_sign_idx is None:
return None
return ((idx - lagna_sign_idx) % 12) + 1
def _blocked(exc: Exception | str) -> dict:
reason = exc if isinstance(exc, str) else f"{exc.__class__.__name__}"
return {"status": "blocked", "reason": reason}
def _planet_lon(pdata: Any) -> float | None:
row = _record(pdata)
for key in ("lon", "degree_raw"):
value = row.get(key)
if isinstance(value, (int, float)):
return float(value)
return None
def _lagna_idx(chart: dict) -> int | None:
asc = _record(chart.get("ascendant"))
if isinstance(asc.get("sign_idx"), int):
return int(asc["sign_idx"]) % 12
return _sign_index(asc.get("sign"))
def _d9_chart(modules: dict) -> dict:
return _record(_record(modules.get("varga_full")).get("D9_Navamsa"))
# ── D9 summary ──────────────────────────────────────────────────────────────
def build_d9_summary(engine, chart: dict, modules: dict) -> dict:
"""D9 lagna, each planet's D9 sign and dignity, Vargottama, D1↔D9 reversals.
D9 dignity: `jyotish_engine._get_dignity_level` with the context rule the
full reading's D9 step uses (`_build_dignity_context` of the D9 positions).
D1 dignity for the reversal check is the chart's own `status` label. The
D1 label itself is already in the base section, so it is not repeated.
Vargottama comes from `jyotish_engine._calc_vargottama`.
"""
d9 = _d9_chart(modules)
d9_planets = _record(d9.get("planets"))
planets = _record(chart.get("planets"))
if not d9_planets or not planets:
return _blocked("d9_or_d1_positions_missing")
d9_context = engine._build_dignity_context(d9_planets)
vargottama = engine._calc_vargottama(planets, {"D9_Navamsa": d9_planets})
labels_to_level = {label: level for level, label in engine.DIGNITY_LABELS.items()}
rows: dict[str, dict] = {}
reversals: list[dict] = []
for name, pdata in planets.items():
d9_row = _record(d9_planets.get(name))
if not isinstance(pdata, dict) or not d9_row.get("sign") or not pdata.get("sign"):
continue
# A D9 Moolatrikona is read from the in-varga degree inside the D1 band
# (engine.VARGA_MOOLATRIKONA_BASIS): 待核 with the astrologer (#166, BUG-1622).
d9_level = engine._get_dignity_level(name, d9_row["sign"], d9_row.get("degree"), d9_context)
# D1: the chart's own status label (what the base section shows); its
# level is read back from the same label table.
d1_label = pdata.get("status")
d1_level = labels_to_level.get(d1_label) if isinstance(d1_label, str) else None
if d1_level is None:
d1_level = engine._get_dignity_level(name, pdata["sign"], pdata.get("degree_in_sign"), planets)
d1_label = engine.DIGNITY_LABELS.get(d1_level, d1_level)
is_vargottama = bool(_record(vargottama.get(name)).get("is_vargottama"))
d9_label = engine.DIGNITY_LABELS.get(d9_level, d9_level)
rows[name] = {
"d9_sign": d9_row["sign"],
"d9_dignity": d9_label,
"vargottama": is_vargottama,
}
if (d1_level in _STRONG_DIGNITIES and d9_level in _WEAK_DIGNITIES) or (
d1_level in _WEAK_DIGNITIES and d9_level in _STRONG_DIGNITIES
):
reversals.append({"planet": name, "d1": d1_label, "d9": d9_label})
d9_lagna = _record(d9.get("ascendant")).get("sign")
d1_lagna = _record(chart.get("ascendant")).get("sign")
return {
"status": "executed",
"source": "jyotish_engine._get_dignity_level / _calc_vargottama on modules.varga_full.D9_Navamsa",
"lagna": d9_lagna,
"lagna_vargottama": bool(d9_lagna and d9_lagna == d1_lagna),
"planets": rows,
"vargottama": [name for name, row in rows.items() if row["vargottama"]],
"reversals": reversals,
"reversal_rule": REVERSAL_RULE,
# BUG-1622: how a D9 Moolatrikona is decided; pending astrologer review.
"moolatrikona_basis": engine.VARGA_MOOLATRIKONA_BASIS,
}
# ── transit positions at the reference date ─────────────────────────────────
def _noon_local_jd(swe, day: datetime, tz: float) -> float:
return swe.julday(day.year, day.month, day.day, 12.0 - float(tz))
def _transit_positions(engine, day: datetime, tz: float, node_mode: str) -> dict:
positions, _ = engine._calc_sidereal_planets_for_jd(
_noon_local_jd(engine.swe, day, tz), node_mode=node_mode, include_ketu=True,
)
return positions if isinstance(positions, dict) else {}
def _node_signs(engine, day: datetime, node_mode: str) -> dict[str, str]:
jd = engine.swe.julday(day.year, day.month, day.day, 12.0)
positions, _ = engine._calc_sidereal_planets_for_jd(jd, node_mode=node_mode, include_ketu=True)
return {
body: _record(_record(positions).get(body)).get("sign")
for body in ("Rahu", "Ketu")
if _record(_record(positions).get(body)).get("sign")
}
def _node_ingresses(engine, start: datetime, days: int, node_mode: str, lagna_idx: int | None) -> list[dict]:
"""Rahu / Ketu sign changes, sampled like ephemeris_events (daily noon UT).
The mean node moves steadily about 0.05 degrees a day, so for it the scan
steps a week at a time and resolves the day only inside a week whose sign
changed; the reported date is the same first daily-noon sample in the new
sign. The true node wobbles and can cross a boundary and back within a
week, so it is sampled every day.
"""
step = 7 if node_mode == "mean" else 1
events: list[dict] = []
previous_day = start
previous = _node_signs(engine, start, node_mode)
offset = 0
while offset < days:
offset = min(offset + step, days)
day = start + timedelta(days=offset)
signs = _node_signs(engine, day, node_mode)
if signs != previous:
prior = previous
probe = previous_day
while probe < day:
probe += timedelta(days=1)
current = _node_signs(engine, probe, node_mode)
for body in ("Rahu", "Ketu"):
if prior.get(body) and current.get(body) and current[body] != prior[body]:
events.append({
"kind": "ingress",
"date": probe.strftime("%Y-%m-%d"),
"body": body.lower(),
"from_sign": prior[body],
"to_sign": current[body],
"to_natal_house": _whole_sign_house(current[body], lagna_idx),
})
prior = current
previous, previous_day = signs, day
return events
def build_slow_transits(engine, chart: dict, modules: dict, *, reference: datetime, tz: float,
ayanamsa: str, node_mode: str) -> dict:
"""Jupiter / Saturn / Rahu / Ketu now, and the key dates of the next twelve months.
Positions: `jyotish_engine._calc_sidereal_planets_for_jd` at local noon of
the reference date. Jupiter / Saturn ingress and station dates:
`ephemeris_events.build_ephemeris_events`. Rahu / Ketu ingress dates: the
same daily-noon sampling over the engine's node positions (the ephemeris
event scanner has no nodes). SAV / BAV of the Jupiter and Saturn signs are
read from modules.ashtakavarga (sav.scores, bav.<planet>.bindus).
"""
try:
from ephemeris_events import build_ephemeris_events
except ModuleNotFoundError: # pragma: no cover
from scripts.ephemeris_events import build_ephemeris_events
lagna_idx = _lagna_idx(chart)
positions = _transit_positions(engine, reference, tz, node_mode)
ashtakavarga = _record(modules.get("ashtakavarga"))
sav_scores = _record(_record(ashtakavarga.get("sav")).get("scores"))
bav = _record(ashtakavarga.get("bav"))
current: dict[str, dict] = {}
for body in ("Jupiter", "Saturn", "Rahu", "Ketu"):
row = _record(positions.get(body))
if not row.get("sign"):
continue
entry = {
"sign": row["sign"],
"degree_in_sign": row.get("degree_in_sign"),
"retrograde": row.get("retrograde"),
"natal_house": _whole_sign_house(row["sign"], lagna_idx),
}
if body in ("Jupiter", "Saturn"):
idx = _sign_index(row["sign"])
if row["sign"] in sav_scores:
entry["sav"] = sav_scores[row["sign"]]
bindus = _record(bav.get(body)).get("bindus")
if isinstance(bindus, list) and idx is not None and idx < len(bindus):
entry["bav"] = bindus[idx]
current[body] = entry
start = reference.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
end = start + timedelta(days=TRANSIT_WINDOW_DAYS)
scan = build_ephemeris_events({
"start_date": start.strftime("%Y-%m-%d"),
"end_date": end.strftime("%Y-%m-%d"),
"ayanamsa": ayanamsa,
"node_mode": node_mode,
"bodies": ["jupiter", "saturn"],
})
events: list[dict] = []
for item in scan.get("events") or []:
if not isinstance(item, dict):
continue
row = {"kind": item.get("kind"), "date": item.get("date"), "body": item.get("body")}
if item.get("kind") == "ingress":
row["from_sign"] = item.get("from_sign")
row["to_sign"] = item.get("to_sign")
row["to_natal_house"] = _whole_sign_house(item.get("to_sign"), lagna_idx)
else:
row["motion"] = item.get("motion")
events.append(row)
events.extend(_node_ingresses(engine, start, TRANSIT_WINDOW_DAYS, node_mode, lagna_idx))
events.sort(key=lambda row: (str(row.get("date")), str(row.get("body")), str(row.get("kind"))))
houses_in_window: dict[str, list[int]] = {}
for body in ("Jupiter", "Saturn"):
houses: list[int] = []
first = _record(current.get(body)).get("natal_house")
if isinstance(first, int):
houses.append(first)
for row in events:
if row.get("body") == body.lower() and row.get("kind") == "ingress":
house = row.get("to_natal_house")
if isinstance(house, int) and house not in houses:
houses.append(house)
houses_in_window[body] = houses
natal_moon_sign = _record(_record(chart.get("planets")).get("Moon")).get("sign")
return {
"status": "executed",
"source": "jyotish_engine._calc_sidereal_planets_for_jd + ephemeris_events.build_ephemeris_events",
"reference_date": reference.strftime("%Y-%m-%d"),
"window": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
"current": current,
# BUG-1156: the house of transit Saturn's sign counted from the natal
# Moon's sign (1 = Saturn over the natal Moon), whole-sign.
"saturn_from_moon_house": _whole_sign_house(
_record(current.get("Saturn")).get("sign"), _sign_index(natal_moon_sign),
),
"key_dates": events,
"houses_in_window": houses_in_window,
"boundary": scan.get("boundary") or "sign ingress and station scan, not a natal trigger",
}
def build_moon_transit(engine, chart: dict, *, reference: datetime, tz: float, node_mode: str) -> dict:
lagna_idx = _lagna_idx(chart)
moon = _record(_transit_positions(engine, reference, tz, node_mode).get("Moon"))
natal_moon = _record(_record(chart.get("planets")).get("Moon"))
if not moon.get("sign"):
return _blocked("moon_transit_unavailable")
return {
"status": "executed",
"source": "jyotish_engine._calc_sidereal_planets_for_jd (local noon)",
"date": reference.strftime("%Y-%m-%d"),
"sign": moon["sign"],
"degree_in_sign": moon.get("degree_in_sign"),
"natal_house": _whole_sign_house(moon["sign"], lagna_idx),
"house_from_natal_moon": _whole_sign_house(moon["sign"], _sign_index(natal_moon.get("sign"))),
"boundary": "month / day-level trigger only; changes sign about every 2.5 days",
}
# ── Punarphoo, Vivah Saham, day / night ─────────────────────────────────────
def build_punarphoo(chart: dict, transit_saturn_lon: float | None) -> dict:
try:
from punarphoo import detect_punarphoo
except ModuleNotFoundError: # pragma: no cover
from scripts.punarphoo import detect_punarphoo
result = detect_punarphoo(_record(chart.get("planets")), transit_saturn_longitude=transit_saturn_lon)
transit = _record(result.get("saturn_return_to_natal_moon"))
return {
"status": result.get("status"),
"source": "punarphoo.detect_punarphoo",
"natal_present": result.get("natal_present"),
"orb_deg": result.get("orb_deg"),
"orb_limit_deg": result.get("orb_limit_deg"),
"same_house": result.get("same_house"),
"saturn_return_to_natal_moon": {
"active": transit.get("active"),
"orb_deg": transit.get("orb_deg"),
"orb_limit_deg": transit.get("orb_limit_deg"),
},
"claim_boundary": result.get("claim_boundary"),
}
def build_vivah_saham(engine, chart: dict, modules: dict | None = None) -> dict:
"""Natal Vivah Saham from the full reading's own producer (Step 16).
Day or night comes from the same evidence as the card's day_night layer
(gulika daynight, Hindu sunrise), so the two never disagree (BUG-1547).
"""
planets = {
name: {"degree": lon}
for name, lon in ((name, _planet_lon(row)) for name, row in _record(chart.get("planets")).items())
if lon is not None
}
asc_lon = _planet_lon(chart.get("ascendant"))
if not planets or asc_lon is None:
return _blocked("natal_longitudes_missing")
is_day = _record(_record(_record(modules).get("gulika")).get("daynight_evidence")).get("is_daytime")
if not isinstance(is_day, bool):
return _blocked("daynight_evidence_missing")
result = engine._calc_vivah_saham(planets, asc_lon, is_daytime=is_day)
if not isinstance(result, dict) or result.get("error"):
return _blocked(str(_record(result).get("error") or "vivah_saham_unavailable"))
return {
"status": "executed",
"source": "jyotish_engine._calc_vivah_saham",
"longitude": result.get("saham_lon"),
"sign": result.get("saham_sign"),
"degree_in_sign": result.get("saham_deg_in_sign"),
"house": result.get("saham_house"),
"formula_variant": result.get("formula_variant"),
"natal_conjuncts": result.get("natal_conjuncts") or [],
"boundary": result.get("note"),
}
def build_day_night(modules: dict) -> dict:
evidence = _record(_record(modules.get("gulika")).get("daynight_evidence"))
if not isinstance(evidence.get("is_daytime"), bool):
return _blocked("daynight_evidence_missing")
return {
"status": "executed",
"source": "chart.modules.gulika.daynight_evidence (swisseph.rise_trans)",
"is_day": evidence["is_daytime"],
}
# ── Double Transit ──────────────────────────────────────────────────────────
def _pac_types(rows: Any) -> list[str]:
return [str(row.get("type")) for row in rows if isinstance(row, dict) and row.get("type")] if isinstance(rows, list) else []
def _double_transit_args(birth_payload: dict, reference: datetime, house: int):
from types import SimpleNamespace
return SimpleNamespace(
year=int(birth_payload["year"]), month=int(birth_payload["month"]), day=int(birth_payload["day"]),
hour=int(birth_payload["hour"]), minute=int(birth_payload["minute"]),
second=birth_payload.get("second") or 0,
lat=float(birth_payload["lat"]), lon=float(birth_payload["lon"]), tz=float(birth_payload["tz"]),
node_mode=str(birth_payload.get("node_mode") or "mean"),
ayanamsa=birth_payload.get("ayanamsa"),
date=reference.strftime("%Y-%m-%d"), house=house,
)
def build_double_transit(engine, chart: dict, modules: dict, *, birth_payload: dict, reference: datetime,
transit_positions: dict) -> dict:
"""Jupiter + Saturn activation per house from `jyotish_engine.cmd_double_transit_pac`.
The engine checks one event house per call (house, its lord, the lagna
lord, the opposite lord; the same in D9 and from the Moon); all twelve are
run. DK and UL are not targets of that producer, so the same PAC rule
(`jyotish_engine._check_pac`) is applied to the DK longitude and the UL
sign for the marriage card; those rows say so.
"""
conclusions: list[dict] = []
summaries: dict[str, str] = {}
for house in range(1, 13):
result = engine.cmd_double_transit_pac(_double_transit_args(birth_payload, reference, house))
if not isinstance(result, dict) or result.get("error"):
return _blocked(str(_record(result).get("error") or "double_transit_unavailable"))
summaries[str(house)] = str(result.get("summary") or "")
for row in result.get("double_transit") or []:
if not isinstance(row, dict):
continue
conclusions.append({
"house": house,
"layer": row.get("layer"),
"target": row.get("target"),
"strength": row.get("strength"),
"jupiter_pac": _pac_types(row.get("jupiter_pac")),
"saturn_pac": _pac_types(row.get("saturn_pac")),
})
active_houses = sorted({row["house"] for row in conclusions})
lagna_idx = _lagna_idx(chart)
jupiter_lon = _planet_lon({"lon": _record(transit_positions.get("Jupiter")).get("degree_raw")})
saturn_lon = _planet_lon({"lon": _record(transit_positions.get("Saturn")).get("degree_raw")})
karaka_targets: dict[str, dict] = {}
if lagna_idx is not None and jupiter_lon is not None and saturn_lon is not None:
dk = _record(_record(_record(modules.get("jaimini")).get("chara_karakas")).get("DK"))
dk_planet = dk.get("planet")
dk_lon = _planet_lon(_record(chart.get("planets")).get(dk_planet)) if dk_planet else None
ul_sign = _record(_record(_record(modules.get("arudha_padas")).get("padas")).get("UL")).get("sign")
ul_idx = _sign_index(ul_sign)
targets = []
if dk_lon is not None:
targets.append(("DK", f"DK({dk_planet})", dk_lon))
if ul_idx is not None:
targets.append(("UL", f"UL({ul_sign})", ul_idx * 30 + 15))
for key, label, target_lon in targets:
jupiter = _pac_types(engine._check_pac("Jupiter", jupiter_lon, target_lon, lagna_idx))
saturn = _pac_types(engine._check_pac("Saturn", saturn_lon, target_lon, lagna_idx))
karaka_targets[key] = {
"target": label,
"jupiter_pac": jupiter,
"saturn_pac": saturn,
"double_transit": bool(jupiter and saturn),
"method": "jyotish_engine._check_pac (same PAC rule as cmd_double_transit_pac; not a target of that producer)",
}
return {
"status": "executed",
"source": "jyotish_engine.cmd_double_transit_pac (houses 1-12)",
"date": reference.strftime("%Y-%m-%d"),
"active_houses": active_houses,
"conclusions": conclusions[:MAX_DOUBLE_TRANSIT_CONCLUSIONS],
"conclusion_count": len(conclusions),
"house_7_summary": summaries.get("7"),
"karaka_targets": karaka_targets,
"boundary": "Jupiter and Saturn activation of the same target on one date; an activation window, not an event",
}
# ── Jaimini / lookup layers ─────────────────────────────────────────────────
def build_karakamsha(chart: dict, modules: dict) -> dict:
try:
from jaimini import calc_karakamsha
except ModuleNotFoundError: # pragma: no cover
from scripts.jaimini import calc_karakamsha
ak = _record(_record(_record(modules.get("jaimini")).get("chara_karakas")).get("AK"))
ak_planet = ak.get("planet")
ak_d9 = _record(_record(_d9_chart(modules).get("planets")).get(ak_planet)) if ak_planet else {}
if not ak_d9.get("sign"):
return _blocked("atmakaraka_d9_missing")
result = calc_karakamsha(ak_d9["sign"], ak_d9.get("degree"))
return {
"status": "executed",
"source": "jaimini.calc_karakamsha (AK in D9)",
"atmakaraka": ak_planet,
"karakamsha_sign": result.get("karakamsha_sign"),
"karakamsha_degree": result.get("karakamsha_degree"),
"karakamsha_lord": result.get("karakamsha_lord"),
"karakamsha_natal_house": _whole_sign_house(result.get("karakamsha_sign"), _lagna_idx(chart)),
}
def build_argala(chart: dict) -> dict:
try:
from argala import calc_argala
except ModuleNotFoundError: # pragma: no cover
from scripts.argala import calc_argala
lagna_idx = _lagna_idx(chart)
indices = {
name: _sign_index(row.get("sign"))
for name, row in _record(chart.get("planets")).items()
if isinstance(row, dict) and _sign_index(row.get("sign")) is not None
}
if lagna_idx is None or not indices:
return _blocked("argala_inputs_missing")
result = calc_argala(indices, lagna_idx)
# 原值:同数由 Narayana 强弱选择器择胜的宫标 tie_break_ruling_pending=true
# 新值:不再借用选择器,同数保持 strength_comparison_required(卡上「待定」),不再有该标记
# 原因:占星师 2026-11-07 rulings.md B10:同数借用 Narayana 强弱无原典依据
return {
"status": "executed", "source": "argala.calc_argala",
"net_result_meaning": ARGALA_NET_RESULT_MEANING,
**result,
}
# 占星师第三轮审查 2026-11-07: `supported` is a structural verdict (the Argala
# planets prevail over the Virodhargala blockers), not "things go well".
ARGALA_NET_RESULT_MEANING = (
"structural intervention only: supported = the Argala (intervening) planets prevail over the "
"Virodhargala blockers; obstructed = the blockers prevail. It says the house is acted on, not that "
"matters go smoothly, and adds no benefic score by itself; the effect depends on the planets' "
"functional roles."
)
def build_dispositor_chains(engine, chart: dict) -> dict:
chains = engine.calc_all_dispositor_chains(_record(chart.get("planets")))
compact = {
name: [
{"planet": step.get("planet"), "sign": step.get("sign"), "dispositor": step.get("dispositor")}
for step in chain if isinstance(step, dict)
]
for name, chain in _record(chains).items() if isinstance(chain, list)
}
return {
"status": "executed" if compact else "blocked",
"source": "jyotish_engine.calc_all_dispositor_chains",
"chains": compact,
"boundary": "D1 dispositor chains; parameter_sensitive",
}
def build_inter_chart_linkage(engine, chart: dict, modules: dict) -> dict:
varga_full = _record(modules.get("varga_full"))
def flat(code: str) -> tuple[dict, int | None]:
data = _record(varga_full.get(code))
return _record(data.get("planets")), _sign_index(_record(data.get("ascendant")).get("sign"))
d9, d9_idx = flat("D9_Navamsa")
d10, d10_idx = flat("D10_Dasamsa")
if not d9 or not d10:
return _blocked("d9_or_d10_missing")
linkage = engine.calc_all_inter_chart_linkages(
_record(chart.get("planets")), d9, d10, None, _lagna_idx(chart), d9_idx, d10_idx,
)
compact = {
name: {
code: {"sign": point.get("sign"), "house": point.get("house"), "lord": point.get("lord")}
for code, point in _record(row).items() if isinstance(point, dict)
}
for name, row in _record(linkage).items()
}
return {
"status": "executed",
"source": "jyotish_engine.calc_all_inter_chart_linkages (D1 / D9 / D10)",
"planets": compact,
"boundary": "placement linkage only; parameter_sensitive",
}
# ── Graha drishti and combustion ────────────────────────────────────────────
def build_graha_drishti(engine, chart: dict) -> dict:
"""Who aspects whom (whole-sign) and which planets are combust.
Aspected houses: `aspects.calc_house_aspects` for the seven grahas (the
full reading's own Step 5 producer). `aspected_by` of a house is every
caster whose aspected houses include it; `aspected_by` of a planet is that
of the house it occupies. Combustion: `jyotish_engine.is_combust`, the
full reading's rule and orbs; written only for the seven grahas.
"""
try:
from aspects import calc_house_aspects
except ModuleNotFoundError: # pragma: no cover
from scripts.aspects import calc_house_aspects
planets = _record(chart.get("planets"))
asc_lon = _planet_lon(chart.get("ascendant"))
lagna_idx = _lagna_idx(chart)
if asc_lon is None or lagna_idx is None or not planets:
return _blocked("natal_longitudes_missing")
rows: dict[str, dict] = {}
houses: dict[str, dict] = {str(number): {"aspected_by": []} for number in range(1, 13)}
for name in DRISHTI_CASTERS:
lon = _planet_lon(planets.get(name))
if lon is None:
continue
result = calc_house_aspects(lon, name, asc_lon)
targets = sorted(int(house) for house in _record(result.get("aspects_to")))
rows[name] = {"house": result.get("house"), "aspects_houses": targets}
for house in targets:
houses[str(house)]["aspected_by"].append(name)
for name, pdata in planets.items():
if not isinstance(pdata, dict):
continue
house = _record(rows.get(name)).get("house") or _whole_sign_house(pdata.get("sign"), lagna_idx)
if not isinstance(house, int):
continue
row = rows.setdefault(name, {"house": house})
row["aspected_by"] = list(houses[str(house)]["aspected_by"])
sun_lon = _planet_lon(planets.get("Sun"))
if sun_lon is not None:
for name in DRISHTI_CASTERS:
lon = _planet_lon(planets.get(name))
if lon is not None and name in rows:
rows[name]["combust"] = bool(engine.is_combust(name, lon, sun_lon, bool(_record(planets.get(name)).get("retrograde"))))
for row in rows.values():
row.pop("house", None)
return {
"status": "executed",
"source": "aspects.calc_house_aspects + jyotish_engine.is_combust",
"rule": DRISHTI_RULE,
"planets": rows,
"houses": houses,
}
def _yoga_packet_tags() -> dict[str, str]:
try:
from raman_support_observations import packet_members
except ModuleNotFoundError: # pragma: no cover
from scripts.raman_support_observations import packet_members
tags: dict[str, str] = {}
for filename, domain in YOGA_PACKET_DOMAINS.items():
for member in packet_members(filename):
if member.get("rule_id"):
tags.setdefault(member["rule_id"], domain)
return tags
def build_yoga_domains(modules: dict) -> dict:
"""The domain tag of each yoga in `modules.yogas` (BUG-1157).
The tag is the Raman packet that governs the yoga's rule id
(`YOGA_PACKET_DOMAINS`); `hit` is the sieve's own value. A yoga whose rule
id is in no packet carries no tag.
"""
entries = _record(modules.get("yogas")).get("yogas")
if not isinstance(entries, list):
return _blocked("yogas_missing")
tags = _yoga_packet_tags()
rows: list[dict] = []
for entry in entries:
row = _record(entry)
if not isinstance(row.get("name"), str):
continue
out: dict[str, Any] = {"name": row["name"], "hit": bool(row.get("hit"))}
tag = tags.get(str(row.get("rule_id") or ""))
if tag:
out["domain"] = tag
rows.append(out)
return {
"status": "executed",
"source": "modules.yogas (Raman sieve) + YOGA_PACKET_DOMAINS",
"rows": rows,
}
def _yoga_key(entry: dict) -> str:
name = "".join(ch for ch in str(entry.get("name") or "").lower() if ch.isalnum())
return name.replace("yoga", "") or name
def merge_detected_yogas(*groups: list) -> list[dict]:
"""Merge yoga lists in order, dropping repeats by normalised name (BUG-1174)."""
merged: list[dict] = []
seen: set[str] = set()
for group in groups:
for entry in group or []:
if not isinstance(entry, dict) or not entry.get("name"):
continue
key = _yoga_key(entry)
if key in seen:
continue
seen.add(key)
merged.append(entry)
return merged
def collect_consultation_yogas(handler, chart: dict, birth_payload: dict, planets: dict,
ascendant: Any, compact_entry, evaluation_out: dict | None = None) -> list[dict]:
"""Every yoga the consultation sieve sees: the chart's own list plus the rule engine.
BUG-1174 (TASK-consult-career-yoga-functional-20261002 T2): the rule engine
used to run only when `chart.yogas` was empty, so a chart that already
carried one or two yogas never had the Raman packet members checked. It now
always runs; the two lists are merged and de-duplicated by name. No
detection rule or sieve boundary changes.
"""
raw = chart.get("yogas") if isinstance(chart.get("yogas"), list) else []
own = [entry for entry in (compact_entry(item) for item in raw) if entry]
try:
api = handler._compute_yogas_api({**birth_payload, "planets": planets, "ascendant": ascendant})
except Exception:
if not own:
raise
return merge_detected_yogas(own)
result = api.get("result") if isinstance(api, dict) else {}
result = result if isinstance(result, dict) else {}
# 占星师第三轮审查 2026-11-07: rules the engine could not evaluate travel
# with the list so the sieve never reports them as "not formed".
if evaluation_out is not None and isinstance(result.get("rule_engine_evaluation"), dict):
evaluation_out.update(result["rule_engine_evaluation"])
engine = [
entry for entry in (
compact_entry(item)
for item in (result.get("extended_yogas") or []) + (result.get("rule_engine_yogas") or [])
)
if entry
]
return merge_detected_yogas(own, engine)
# Closed list for `traditional_yogas` (TASK-consult-career-yoga-functional-20261002
# D3, BUG-1175): canonical name -> name-token prefixes the detectors emit
# (yoga_expansion / yoga_engine / chart.yogas / Raman packet hits). Nothing
# outside this list is shown, and no detector is added here.
TRADITIONAL_YOGA_LABEL = "传统格局,本站未验证,只作参考"
TRADITIONAL_YOGAS = (
("Ruchaka Yoga", ("ruchaka",)),
("Bhadra Yoga", ("bhadra",)),
("Hamsa Yoga", ("hamsa",)),
("Malavya Yoga", ("malavya",)),
("Sasa Yoga", ("sasa", "shasha")),
("Gajakesari Yoga", ("gajakesari",)),
# BUG-1205 (乙14): never called Raja Yoga; the card lists the conditions that hold.
("落陷取消(Neecha Bhanga)", ("neechabhanga", "neechbhanga")),
# Budha-Aditya: the engine also emits the same Sun-Mercury conjunction as
# "Surya-Budha Yoga"; both map to one row.
("Budha-Aditya Yoga", ("budhaaditya", "budhaditya", "suryabudha")),
)
def _traditional_name(name: Any) -> str | None:
token = "".join(ch for ch in str(name or "").lower() if ch.isalnum())
for canonical, prefixes in TRADITIONAL_YOGAS:
if any(token.startswith(prefix) for prefix in prefixes):
return canonical
return None
def build_traditional_yogas(modules: dict) -> dict:
"""Closed-list classical yogas the detectors found formed (BUG-1175).
Reads what the consultation sieve was handed (`raman_support.yogas`: packet
hits plus `ungoverned_detected`). Only formed yogas are listed; the label is
fixed. Reading rule (card): strength only, no stand-alone conclusion, never
counted as affliction or support against affliction. Raman packets,
`modules.yogas`, confidence caps and claim boundaries are unchanged.
"""
sieve = _record(_record(modules.get("raman_support")).get("yogas"))
if sieve.get("status") != "executed":
return _blocked("raman_support_yogas_missing")
found: dict[str, list[str]] = {}
notes: dict[str, list[str]] = {}
for entry in list(sieve.get("hits") or []) + list(sieve.get("ungoverned_detected") or []):
row = _record(entry)
if row.get("hit") is False:
continue
canonical = _traditional_name(row.get("name"))
if not canonical:
continue
planets = found.setdefault(canonical, [])
for planet in row.get("planets") or []:
if isinstance(planet, str) and planet not in planets:
planets.append(planet)
# Main-detector rows: the conditions that hold (Neecha Bhanga, BUG-1205)
# and what weakens a formed Pancha Mahapurusha yoga (BUG-1207).
bucket = notes.setdefault(canonical, [])
for key, prefix in (("conditions", "成立条件:"), ("reductions", "部分减弱:")):
items = [str(item) for item in row.get(key) or [] if item]
if items:
text = prefix + ";".join(items)
if text not in bucket:
bucket.append(text)
def _label(canonical: str) -> str:
parts = ["、".join(found[canonical])] if found[canonical] else []
parts += notes.get(canonical) or []
return f"{canonical}({';'.join(parts)})" if parts else canonical
names = [
_label(canonical)
for canonical, _prefixes in TRADITIONAL_YOGAS
if canonical in found
]
return {
"status": "executed",
"source": "raman_support.yogas (hits + ungoverned_detected), closed list TRADITIONAL_YOGAS",
"label": TRADITIONAL_YOGA_LABEL,
"names": names,
}
# ── Annual Tajika / Varshaphala ─────────────────────────────────────────────
def _annual_payload(birth_payload: dict, target_year: int) -> dict:
tz = float(birth_payload["tz"])
sign = "-" if tz < 0 else "+"
minutes = int(round(abs(tz) * 60))
second = int(float(birth_payload.get("second") or 0))
return {
"birth": {
"date": f"{int(birth_payload['year']):04d}-{int(birth_payload['month']):02d}-{int(birth_payload['day']):02d}",
"time": f"{int(birth_payload['hour']):02d}:{int(birth_payload['minute']):02d}:{second:02d}",
"latitude": float(birth_payload["lat"]),
"longitude": float(birth_payload["lon"]),
"utc_offset": f"{sign}{minutes // 60:02d}:{minutes % 60:02d}",
},
"settings": {
"ayanamsa": birth_payload.get("ayanamsa") or "raman",
"node_mode": birth_payload.get("node_mode") or "mean",
},
"target_year": int(target_year),
}
def _solar_return_local(pack: dict, tz: float) -> datetime | None:
data = _record(_record(pack.get("solar_return")).get("data"))
raw = data.get("dt_ut")
try:
moment = raw if isinstance(raw, datetime) else datetime.strptime(str(raw)[:19], "%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError):
return None
return moment + timedelta(hours=float(tz))
def _mudda_segments(periods: list, start: datetime, end: datetime, anchor: Any = None) -> list[dict]:
"""Calendar dates of the native Mudda Dasha segments.
`tajika_mudda.annual_mudda_dasha` (BUG-1216: first lord from the natal
Moon nakshatra + completed years) gives each segment's start / end inside
the Varsha year, anchored at the solar-return instant `anchor`. They are
re-anchored on this consultation's year [start, end) and the last one is
cropped at the next solar return. Rows without dates fall back to the
months / 12 share of the span.
"""
try:
origin = datetime.fromisoformat(str(anchor)) if anchor else None
except ValueError:
origin = None
span = end - start
cursor = start
rows: list[dict] = []
for period in periods:
if not isinstance(period, dict) or not isinstance(period.get("months"), (int, float)):
continue
try:
seg_start = start + (datetime.fromisoformat(str(period["start"])) - origin)
seg_end = start + (datetime.fromisoformat(str(period["end"])) - origin)
except (KeyError, TypeError, ValueError):
seg_start, seg_end = cursor, cursor + span * (float(period["months"]) / 12.0)
seg_end = min(seg_end, end)
if seg_end <= seg_start:
continue
rows.append({
"lord": period.get("lord"),
"months": period.get("months"),
"start": seg_start.strftime("%Y-%m-%d"),
"end": seg_end.strftime("%Y-%m-%d"),
})
cursor = seg_end
return rows
# 原值:「按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。回归后的年度判断
# 转入新年盘,旧周期延伸部分保留供方法对照。」新值:占星师原话,原始延伸终点放括号里。
# 原因:占星师 2026-11-08 第五轮裁定 2。
MUDDA_CROSSING_TEXT = "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至{end})"
def _mudda_view(data: dict, start: datetime, end: datetime) -> dict:
"""Label of the clipped Mudda rows and, when one exists, the segment crossing the next return."""
coverage = _record(data.get("coverage_interval"))
view = {
"clipped_to": "coverage_interval",
"coverage": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d"),
"basis": coverage.get("basis") or "actual_solar_return_to_next_actual_solar_return"},
"nominal_year_days": _record(data.get("nominal_year")).get("days"),
"label": "本年盘只解释到下一次太阳回归;Mudda 原始完整时间线另存于年运数据包",
}
try:
origin = datetime.fromisoformat(str(data.get("annual_start")))
cover_end = datetime.fromisoformat(str(coverage.get("end"))) if coverage.get("end") else None
except ValueError:
return view
for row in data.get("raw_periods") or []:
try:
row_start, row_end = datetime.fromisoformat(str(row["start"])), datetime.fromisoformat(str(row["end"]))
except (KeyError, TypeError, ValueError):
continue
if cover_end and row_start < cover_end < row_end:
raw_end = start + (row_end - origin)
view["crossing_segment"] = {
"lord": row.get("lord"),
"raw_end": raw_end.strftime("%Y-%m-%d"),
"text": MUDDA_CROSSING_TEXT.format(end=raw_end.strftime("%Y-%m-%d")),
}
break
return view
def _calendar_year_span(birth_payload: dict, solar_field: dict, ref: datetime, start: datetime, end: datetime,
target: int, tz: float) -> dict:
"""A calendar year (what users usually mean by 今年) spans two Tajika years split at the return (her Q2).
Gives both Tajika years' coverage and the return date that splits the
calendar year. Only the year containing ``ref`` has its annual chart in
this card; the other year is coverage only.
"""
year = ref.year
if start.year == year:
split, pair = start, (("previous", target - 1, None, start), ("attached", target, start, end))
other_year = target - 1
else:
split, pair = end, (("attached", target, start, end), ("next", target + 1, end, None))
other_year = target + 2
try:
import swisseph as swe
from solar_return import find_solar_return_ut
data = _record(solar_field.get("data"))
birth_ut = (datetime(int(birth_payload["year"]), int(birth_payload["month"]), int(birth_payload["day"]),
int(birth_payload["hour"]), int(birth_payload["minute"]), int(float(birth_payload.get("second") or 0)))
- timedelta(hours=tz))
birth_jd = swe.julday(birth_ut.year, birth_ut.month, birth_ut.day,
birth_ut.hour + birth_ut.minute / 60 + birth_ut.second / 3600)
found = find_solar_return_ut(birth_jd, float(data["sun_lon"]), other_year,
ayanamsa_name=birth_payload.get("ayanamsa") or "raman")
other = found["dt_ut"] + timedelta(hours=tz) if isinstance(found.get("dt_ut"), datetime) else None
except Exception: # noqa: BLE001 - the other year's edge stays unknown, labelled
other = None
rows = []
for role, target_year, seg_start, seg_end in pair:
seg_start = seg_start or (other if role == "previous" else None)
seg_end = seg_end or (other if role == "next" else None)
rows.append({
"target_year": target_year,
"role": "annual_chart_in_this_card" if role == "attached" else "annual_chart_not_in_this_card",
"coverage": {"start": seg_start.strftime("%Y-%m-%d") if seg_start else None,
"end": seg_end.strftime("%Y-%m-%d") if seg_end else None},
"calendar_part": {"start": max(seg_start, datetime(year, 1, 1)).strftime("%Y-%m-%d") if seg_start else f"{year}-01-01",
"end": min(seg_end, datetime(year + 1, 1, 1)).strftime("%Y-%m-%d") if seg_end else f"{year + 1}-01-01"},
})
return {
"year": year,
"split_at_return": split.strftime("%Y-%m-%d"),
"tajika_years": rows,
"rule": "用户说「今年」通常指公历年:公历年跨两张年盘,在太阳回归日分开,各段按各自年盘解释;「未来十二个月」按用户给的起止日期",
}
def build_annual_tajika(birth_payload: dict, chart: dict, *, reference: datetime) -> dict:
try:
from annual_tajika_pack import build_annual_tajika_pack
except ModuleNotFoundError: # pragma: no cover
from scripts.annual_tajika_pack import build_annual_tajika_pack
tz = float(birth_payload["tz"])
ref = reference.replace(tzinfo=None)
target = ref.year
pack = build_annual_tajika_pack(_annual_payload(birth_payload, target))
start = _solar_return_local(pack, tz)
if start is not None and start > ref:
target -= 1
pack = build_annual_tajika_pack(_annual_payload(birth_payload, target))
start = _solar_return_local(pack, tz)
following = build_annual_tajika_pack(_annual_payload(birth_payload, target + 1))
end = _solar_return_local(following, tz)
fields = {key: _record(pack.get(key)) for key in ("solar_return", "annual_chart", "muntha", "year_lord", "tajika_yogas", "mudda_dasha")}
producers = {key: field.get("status") for key, field in fields.items()}
periods = fields["mudda_dasha"].get("periods")
required = ("solar_return", "annual_chart", "muntha", "year_lord", "mudda_dasha")
# BUG-1223: a year lord the producers blocked (annual chart incomplete,
# no stand-in planet) does not block the card; it shows
# "年主暂无法确定(原因)". Mudda does not depend on the year lord.
year_lord_blocked = _record(fields["year_lord"].get("blocked_detail")) if producers.get("year_lord") == "blocked" else {}
unreliable = [key for key in required
if producers.get(key) != "partial_verified" and not (key == "year_lord" and year_lord_blocked)]
if start is None or end is None or end <= start:
unreliable.append("solar_return_dates")
if not isinstance(periods, list) or not periods:
unreliable.append("mudda_dasha_periods")
if unreliable:
return {
"status": "blocked",
"reason": "annual_pack_not_reliable:" + ",".join(dict.fromkeys(unreliable)),
"source": "annual_tajika_pack.build_annual_tajika_pack",
"producers": producers,
"boundary": "annual chart not attached; natal data is not a substitute",
}
annual_chart = _record(fields["annual_chart"].get("data"))
muntha = _record(fields["muntha"].get("data"))
year_lord_data = year_lord_blocked or _record(fields["year_lord"].get("data"))
year_lord = year_lord_data.get("year_lord")
annual_lagna_idx = _sign_index(annual_chart.get("asc_sign"))
focus = {"Sun", "Moon", year_lord}
aspects = []
for row in _record(fields["tajika_yogas"].get("data")).get("candidate_yogas") or []:
if not isinstance(row, dict):
continue
pair = row.get("planets") if isinstance(row.get("planets"), list) else []
if not focus.intersection(pair):
continue
aspects.append({
"name": row.get("name"),
"planets": pair,
"aspect": row.get("aspect"),
"motion": row.get("motion"),
})
return {
"status": "parameter_sensitive",
"source": "annual_tajika_pack.build_annual_tajika_pack (solar_return_full_report + cmd_tajika)",
"target_year": target,
"age": annual_chart.get("age"),
"varsha_year": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
"annual_lagna": annual_chart.get("asc_sign"),
"varshesha": year_lord,
"varshesha_basis": year_lord_data.get("selection_basis_text") or YEAR_LORD_BASIS_MISSING,
"muntha": {
"sign": muntha.get("muntha_sign"),
"lord": muntha.get("muntha_lord"),
"house_in_annual_chart": _whole_sign_house(muntha.get("muntha_sign"), annual_lagna_idx),
"house_in_natal_chart": _whole_sign_house(muntha.get("muntha_sign"), _lagna_idx(chart)),
},
"mudda_dasha": _mudda_segments(periods, start, end, _record(fields["mudda_dasha"].get("data")).get("annual_start")),
# BUG-1722 (her Q2): the rows above are a view clipped to this return -> next return;
# the raw Mudda timeline is kept whole in the annual pack.
"mudda_view": _mudda_view(_record(fields["mudda_dasha"].get("data")), start, end),
"calendar_year": _calendar_year_span(birth_payload, fields["solar_return"], ref, start, end, target, tz),
"tajika_aspects": aspects,
"tajika_aspects_status": producers.get("tajika_yogas"),
"producers": producers,
# 占星师第三轮审查 2026-11-07: what each producer's partial_verified covers; no date is verified.
"verification_scope": {key: {"verified_parts": field.get("verified_parts"), "dates_verified": field.get("dates_verified")}
for key, field in fields.items() if "verified_parts" in field},
# BUG-1699: upstream source-backed Raman Saham layer (scripts/raman_sahams.py).
"raman_sahams": _raman_saham_rows(_record(pack.get("raman_sahams")), annual_lagna_idx),
# 产品 2026-11-07 与占星师上游年运同步:年盘特殊点、Mudda 另两种余额口径、
# Tajika 名称 Yoga(只列出,不作判断因素,3509ccd7)。
"special_points": _annual_special_point_rows(_record(pack.get("annual_special_points")), annual_lagna_idx),
# Default stays the birth-Moon profile (astrologer round-5 Q7, BUG-1216).
"mudda_default_profile": _record(pack.get("mudda_profiles")).get("default_profile"),
"mudda_default_basis": _record(pack.get("mudda_profiles")).get("default_basis_text"),
"mudda_profiles": _mudda_profile_rows(_record(pack.get("mudda_profiles")), start, end),
"tajika_named_yogas": _named_yoga_rows(_record(pack.get("tajika_named_yogas"))),
"boundary": TAJIKA_BOUNDARY,
}
def _annual_special_point_rows(result: dict, annual_lagna_idx: int | None) -> dict:
data = _record(result.get("data"))
points = {}
for key in ("arudha_lagna", "bhrigu_bindu", "gulika"):
point = _record(data.get(key))
if point.get("status") != "computed":
points[key] = {"status": "blocked", "reason": point.get("reason") or f"{key}_missing"}
continue
item = {"sign": point.get("sign"), "house_in_annual_chart": _whole_sign_house(point.get("sign"), annual_lagna_idx)}
if isinstance(point.get("degree_in_sign"), (int, float)):
item["degree_in_sign"] = round(float(point["degree_in_sign"]), 2)
points[key] = item
return {"status": result.get("status") or "blocked", "source": "annual_tajika_pack.annual_special_points", **points}
def _mudda_profile_rows(result: dict, start: datetime, end: datetime) -> list[dict]:
rows = []
for key, profile in _record(result.get("profiles")).items():
profile = _record(profile)
row = {"balance_profile": key, "label": profile.get("label"), "is_default": bool(profile.get("is_default")),
"status": profile.get("status")}
if profile.get("status") == "blocked":
row["reason"] = profile.get("reason")
else:
row["starting_lord"] = profile.get("starting_lord")
row["rule"] = profile.get("rule_text")
row["segments"] = _mudda_segments(profile.get("periods") or [], start, end, profile.get("annual_start"))
rows.append(row)
return rows
def _named_yoga_rows(result: dict) -> dict:
data = _record(result.get("data"))
rows = [{"name": row.get("named_yoga"), "planets": row.get("planets")}
for row in data.get("rows") or [] if isinstance(row, dict) and row.get("named_yoga")]
return {"status": result.get("status") or "blocked", "use": "listed_not_judgment_factor", "rows": rows}
def _raman_saham_rows(result: dict, annual_lagna_idx: int | None) -> dict:
if result.get("status") != "partial_verified":
return {"status": "blocked", "reason": result.get("reason") or "raman_sahams_unavailable"}
rows = []
for row in result.get("rows") or []:
if not isinstance(row, dict):
continue
item = {"name": row.get("name"), "status": row.get("status")}
if row.get("status") == "blocked":
item["reason"] = row.get("reason")
else:
item.update({
"sign": row.get("sign"),
"degree_in_sign": row.get("degree_in_sign"),
"house_in_annual_chart": _whole_sign_house(row.get("sign"), annual_lagna_idx),
"formula_operands": row.get("formula_operands"),
"formula_variant": row.get("formula_variant"),
})
rows.append(item)
return {
"status": "parameter_sensitive",
"source": "annual_tajika_pack.raman_sahams (raman_sahams.build_raman_sahams)",
"is_daytime": result.get("is_daytime"),
"annual_house_profile": result.get("annual_house_profile"),
"rows": rows,
"boundary": result.get("boundary"),
}
# ── BUG-1699: upstream calculation layers ─────────────────────────────────────
# Houses read for affliction evidence, with the reading domains upstream's
# shared weight profile names (affliction_evidence.validate_affliction_weight_profile).
AFFLICTION_TARGET_HOUSES = (
(1, "health"), (4, "parents"), (5, "education"), (6, "health"),
(7, "marriage"), (9, "parents"), (10, "career"),
)
SADE_SATI_YEARS_BEFORE = 30
SADE_SATI_YEARS_AFTER = 65
def _affliction_planets(engine, chart: dict) -> dict[str, dict]:
"""Whole-sign house, D1 debility and combustion per planet (inputs only)."""
planets = _record(chart.get("planets"))
lagna_idx = _lagna_idx(chart)
context = engine._build_dignity_context(planets)
sun_lon = _planet_lon(planets.get("Sun"))
rows: dict[str, dict] = {}
for name, pdata in planets.items():
if not isinstance(pdata, dict) or not pdata.get("sign"):
continue
house = _whole_sign_house(pdata.get("sign"), lagna_idx)
if not isinstance(house, int):
continue
row: dict[str, Any] = {"house": house, "sign": pdata.get("sign")}
if name in DRISHTI_CASTERS:
level = engine._get_dignity_level(name, pdata["sign"], pdata.get("degree_in_sign", pdata.get("degree")), context)
row["debilitated"] = level == "DEBILITATED"
lon = _planet_lon(pdata)
if name != "Sun" and lon is not None and sun_lon is not None:
row["combust"] = bool(engine.is_combust(name, lon, sun_lon, bool(pdata.get("retrograde"))))
rows[name] = row
return rows
def _functional_malefics(modules: dict) -> list[str]:
layer = _record(modules.get("functional_benefic_malefic"))
values = layer.get("functional_malefics")
return [str(v) for v in values] if isinstance(values, list) else []
def build_affliction_evidence(engine, chart: dict, modules: dict) -> dict:
"""`affliction_evidence.collect_affliction_evidence` for the domain houses.
The house lord is the whole-sign lord of the house; functional malefics are
the consultation's own `modules.functional_benefic_malefic`. Signals only:
upstream assigns no weight or verdict, and the shared weight profile stays
`parameter_sensitive` until weights are supplied.
"""
try:
from affliction_evidence import collect_affliction_evidence, validate_affliction_weight_profile
from functional_benefics import SIGN_LORDS
except ModuleNotFoundError: # pragma: no cover
from scripts.affliction_evidence import collect_affliction_evidence, validate_affliction_weight_profile
from scripts.functional_benefics import SIGN_LORDS
lagna_idx = _lagna_idx(chart)
if lagna_idx is None:
return _blocked("lagna_missing")
planets = _affliction_planets(engine, chart)
malefics = _functional_malefics(modules)
d9_pressure = bool(_record(modules.get("varga_full")).get("D9_Navamsa"))
houses: dict[str, dict] = {}
for house, domain in AFFLICTION_TARGET_HOUSES:
lord = SIGN_LORDS[SIGNS[(lagna_idx + house - 1) % 12]]
evidence = collect_affliction_evidence(
target_house=house, target_lord=lord, planets=planets,
functional_malefics=malefics, varga_pressure=False,
)
evidence["domain"] = domain
houses[str(house)] = evidence
return {
"status": "executed",
"source": "affliction_evidence.collect_affliction_evidence (upstream yinduzhanxing)",
"functional_malefics": malefics,
"houses": houses,
"weight_profile": validate_affliction_weight_profile(None),
"varga_pressure_input": "not_supplied" if not d9_pressure else "not_supplied_d9_available",
"boundary": "Signals indicate possible pressure or distance only; no weight, threshold or verdict.",
}
def build_career_analysis(engine, chart: dict, modules: dict, body: dict) -> dict:
"""Upstream `career_analysis.analyze_career` with the consultation's own layers."""
try:
from career_analysis import analyze_career
from career_semantic_profile import build_career_execution_audit, build_career_semantic_profile
except ModuleNotFoundError: # pragma: no cover
from scripts.career_analysis import analyze_career
from scripts.career_semantic_profile import build_career_execution_audit, build_career_semantic_profile
asc_sign = _record(chart.get("ascendant")).get("sign")
if asc_sign not in SIGNS:
return _blocked("ascendant_sign_missing")
planets = _affliction_planets(engine, chart)
# 原值:事业层只拿到 house / sign(_affliction_planets 的行),analyze_career 内的
# moon_paksha_from_planets 读不到经度,动星座上升的 movable_lagna_moon 因此 blocked;
# 同一张盘的基础层却按 D1 经度给出 waning / neutral,两层结论不一致。
# 新值:把 D1 经度一并交给 analyze_career,两层用同一个盈亏判定。
# 原因:占星师 2026-11-08 验收清单 §7 反例「缺月相」:同一张盘两层必须同源(BUG-1812)。
d1_planets = _record(chart.get("planets"))
career_planets = {}
for name, row in planets.items():
lon = _planet_lon(d1_planets.get(name))
career_planets[name] = {**row, **({"lon": lon} if lon is not None else {})}
semantic_modules = {**modules, "chart": {"ascendant": chart.get("ascendant"), "planets": chart.get("planets")}}
western = modules.get("western_evidence_packet") if isinstance(modules.get("western_evidence_packet"), dict) else None
vimsopaka = modules.get("vimsopaka_semantic_summary") if isinstance(modules.get("vimsopaka_semantic_summary"), dict) else None
semantic = build_career_semantic_profile(
semantic_modules, western, vimsopaka,
build_career_execution_audit(semantic_modules, western, vimsopaka),
)
a10 = _record(_record(modules.get("arudha_padas")).get("padas")).get("A10") or None
industry = body.get("user_industry") or body.get("industry")
industry = str(industry).strip() if isinstance(industry, str) and industry.strip() else None
analysis = analyze_career(
career_planets, asc_sign,
d10_positions=_record(modules.get("varga_full")).get("D10_Dasamsa"),
dasha_info=_record(modules.get("dasha")),
shadbala=_record(modules.get("shadbala")),
career_semantic_profile=semantic,
a10=a10,
user_industry=industry,
)
analysis["status"] = "executed"
analysis["source"] = "career_analysis.analyze_career + career_semantic_profile (upstream yinduzhanxing)"
analysis["career_semantic_profile"] = semantic
return analysis
def build_sade_sati_timeline(chart: dict, modules: dict, *, birth_payload: dict, reference: datetime,
tz: float, ayanamsa: str) -> dict:
"""`sade_sati.build_saturn_cycle_timeline` with the bisected ingress moments."""
try:
from sade_sati import build_saturn_cycle_timeline
except ModuleNotFoundError: # pragma: no cover
from scripts.sade_sati import build_saturn_cycle_timeline
planets = _record(chart.get("planets"))
moon_sign = _record(planets.get("Moon")).get("sign")
asc_sign = _record(chart.get("ascendant")).get("sign")
birth_date = None
try:
birth_date = datetime(int(birth_payload["year"]), int(birth_payload["month"]), int(birth_payload["day"])).date()
except (KeyError, TypeError, ValueError):
birth_date = None
timeline = build_saturn_cycle_timeline(
str(moon_sign or ""), str(asc_sign or ""),
start_year=reference.year - SADE_SATI_YEARS_BEFORE,
end_year=reference.year + SADE_SATI_YEARS_AFTER,
tz=tz, ayanamsa=ayanamsa,
ashtakavarga=_record(modules.get("ashtakavarga")),
kantaka_reference="moon", kantaka_houses=(4, 7, 8, 10),
birth_date=birth_date,
)
if timeline.get("status") == "blocked":
return timeline
timeline.pop("pl9_display_rows", None) # print-layout copy of the same rows
timeline["source"] = "sade_sati.build_saturn_cycle_timeline (upstream yinduzhanxing)"
return timeline
def build_shadbala_chesta_profiles(modules: dict, body: dict) -> dict:
"""Upstream Chesta profile candidates and the profile this chart's Chesta used."""
try:
from shadbala_chesta_profiles import (
DEFAULT_CHESTA_PROFILE, build_chesta_profile_candidates, resolve_chesta_profile_selection,
)
except ModuleNotFoundError: # pragma: no cover
from scripts.shadbala_chesta_profiles import (
DEFAULT_CHESTA_PROFILE, build_chesta_profile_candidates, resolve_chesta_profile_selection,
)
shadbala = _record(modules.get("shadbala"))
if not _record(shadbala.get("planets")):
return _blocked("shadbala_missing")
variants = _record(shadbala.get("method_variants"))
candidates = build_chesta_profile_candidates()
return {
"status": "executed",
"source": "shadbala_chesta_profiles (upstream shadbala.py Chesta profile candidates)",
"chart_chesta_profile": variants.get("chesta") or DEFAULT_CHESTA_PROFILE,
"selection": resolve_chesta_profile_selection(body.get("chesta_profile") or body.get("chestaProfile")),
"candidate_profiles": candidates.get("candidate_profiles", []),
"closure_status": candidates.get("closure_status"),
"variant_families": sorted({row.get("variant_family") for row in candidates.get("planet_rows", []) if row.get("variant_family")}),
"boundary": candidates.get("boundary"),
}
def build_book_rules(routes: set[str]) -> dict:
try:
from book_rule_layer import build_book_rule_layer
except ModuleNotFoundError: # pragma: no cover
from scripts.book_rule_layer import build_book_rule_layer
return build_book_rule_layer(routes or {"general"})
# ── entry point ─────────────────────────────────────────────────────────────
# ── Card layers (产品 2026-11-07 占星师方案:回答模型拿到完整数据) ─────────────
#
# Everything below reads an existing producer and selects from it; the only
# values computed here are whole-sign houses of signs a producer returned, the
# house of a point counted from the natal Moon, the running period of a dasha
# timeline at the reference date, and Narayana calendar dates from the
# engine's own start / end ages (date_basis says how).
KP_CUSP_KEYS = ("rasi_lord", "nakshatra_lord", "sub_lord", "sub_sub_lord")
# 原值:「birth date + engine start_age / end_age × 365.25 days; ages are rounded to 0.01 year, so a date can be a few days off」
# 新值:引擎自己用未取整年龄给出日期,这里直接读。原因:三方对照 2026-11-07:产品侧实现缺陷。
NARAYANA_DATE_BASIS = (
"birth date-time + unrounded engine age × 365.25 days (engine start_date / end_date)"
)
SLOW_BODIES = ("Jupiter", "Saturn", "Rahu", "Ketu")
SPECIAL_POINT_KEYS = {
"upagrahas": ("Gulika", "Maandi"),
"special_lagnas": (
"Bhava_Lagna", "Hora_Lagna", "Ghati_Lagna", "ViGhati_Lagna", "Pranapada_Lagna",
"Sree_Lagna", "Indu_Lagna", "Varnada_Lagna",
),
"yogi_points": ("Yogi_Point", "Avayogi_Point"),
"sensitive_points": ("Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"),
"sahams": (
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Vivaha_Saham", "Artha_Saham", "Labha_Saham",
"Karyasiddhi_Saham", "Paradesa_Saham", "Putra_Saham", "Matri_Saham", "Pitri_Saham",
"Roga_Saham", "Mrityu_Saham", "Karma_Saham", "Rajya_Saham",
),
}
SPECIAL_POINT_ROW_KEYS = ("sign", "degree_in_sign", "house", "nakshatra", "lord", "formula_variant")
# Dasha systems the registry lists but no engine module computes.
DASHA_NOT_IMPLEMENTED = ("tara", "bhrigu", "sudasa", "mandooka", "lagnamsaka", "yogardha", "brahma",
"tithi_ashtottari", "tithi_yogini", "paryaaya", "rashmi", "naisargika", "aayu",
"karaka", "panchasvara", "sudarsana")
CONDITIONAL_DASHAS = ("shodasottari", "dwadashottari", "dwisaptati", "shattrimsa", "panchottari",
"satabdika", "chaturaaseeti", "shasti_hayani")
RASHI_DASHA_ORDER = ("chara", "sthira", "drig", "shoola", "niryaana_shoola", "navamsha", "lagna_kendradi")
def _birth_datetime(birth_payload: dict) -> datetime:
return datetime(
int(birth_payload["year"]), int(birth_payload["month"]), int(birth_payload["day"]),
int(birth_payload.get("hour") or 0), int(birth_payload.get("minute") or 0), int(float(birth_payload.get("second") or 0)),
)
def _iso(value: Any) -> str | None:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
if isinstance(value, str) and len(value) >= 10:
return value[:10]
return None
def _stamp(value: Any, tz: timezone | None) -> str | None:
"""Full timestamp with its UTC offset, when the producer has a time of day.
占星师第三轮审查 2026-11-07: the card's `start` / `end` stay dates for
display; `start_at` / `end_at` carry the producer's full instant. Naive
producer values are the birth local clock and get the birth UTC offset.
"""
if isinstance(value, datetime):
moment = value
elif isinstance(value, str) and len(value.strip()) > 10:
try:
moment = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
else:
return None
if moment.tzinfo is None and tz is not None:
moment = moment.replace(tzinfo=tz)
return moment.isoformat(timespec="seconds")
def _birth_zone(birth_payload: dict) -> timezone:
return timezone(timedelta(hours=float(birth_payload.get("tz") or 0)))
def _fmt_tz(zone: timezone) -> str:
offset = zone.utcoffset(None) or timedelta(0)
minutes = int(offset.total_seconds() // 60)
sign = "+" if minutes >= 0 else "-"
return f"{sign}{abs(minutes) // 60:02d}:{abs(minutes) % 60:02d}"
def _sequence_check(items: list, start_key: str, end_key: str, tz: timezone | None, label: str) -> dict:
"""Continuity of one period list on full timestamps (dates when that is all there is)."""
_scripts_on_path()
from chart_dasha import timeline_boundaries as tb
rows = []
for item in items:
record = _record(item)
start = _stamp(record.get(start_key), tz) or _iso(record.get(start_key))
end = _stamp(record.get(end_key), tz) or _iso(record.get(end_key))
rows.append({"start": start, "end": end})
check = tb.check_continuity(rows, label=label)
if rows and all(len(str(row.get("start") or "")) == 10 for row in rows):
check["resolution"] = "date_only"
return check
def _owned_houses(modules: dict) -> dict:
return _record(_record(modules.get("functional_benefic_malefic")).get("owned_houses"))
def _lord_houses(chart: dict, modules: dict, planet: Any) -> dict | None:
if not isinstance(planet, str):
return None
row = _record(_record(chart.get("planets")).get(planet))
out: dict[str, Any] = {}
if isinstance(row.get("house"), int):
out["in"] = row["house"]
owns = _owned_houses(modules).get(planet)
if isinstance(owns, list):
out["owns"] = list(owns)
return out or None
def _running(rows: list, at: str, start_key: str = "start", end_key: str = "end") -> dict | None:
for row in rows:
item = _record(row)
start, end = _iso(item.get(start_key)), _iso(item.get(end_key))
if start and end and start <= at < end:
return item
return None
def _period(item: dict | None, chart: dict, modules: dict, *, start_key: str = "start", end_key: str = "end",
lord_key: str | None = None, tz: timezone | None = None) -> dict | None:
if not item:
return None
out: dict[str, Any] = {}
lagna_idx = _lagna_idx(chart)
sign = item.get("sign") or item.get("rashi")
if isinstance(sign, str):
out["sign"] = sign
lord = item.get(lord_key) if lord_key else (item.get("lord") or item.get("planet"))
if isinstance(lord, str):
out["lord"] = lord
for key, name in ((start_key, "start"), (end_key, "end")):
if _iso(item.get(key)):
out[name] = _iso(item.get(key))
full = _stamp(item.get(key), tz)
if full:
out[f"{name}_at"] = full
if isinstance(sign, str):
house = _whole_sign_house(sign, lagna_idx)
if house is not None:
out["house"] = house
elif isinstance(lord, str):
houses = _lord_houses(chart, modules, lord)
if houses:
out["lord_houses"] = houses
return out
def _timeline_row(system: str, periods: list, chart: dict, modules: dict, at: str, *,
start_key: str = "start", end_key: str = "end", child_key: str = "antardasha",
grandchild_key: str = "pratyantardasha", tz: timezone | None = None, **extra: Any) -> dict:
md = _running(periods, at, start_key, end_key)
row: dict[str, Any] = {"system": system, "applicable": True, **{k: v for k, v in extra.items() if v is not None}}
_attach_continuity(row, [_sequence_check(periods, start_key, end_key, tz, f"{system}.md")] + (
[_sequence_check(list(md.get(child_key) or []), start_key, end_key, tz, f"{system}.running_md.{child_key}")]
if md and md.get(child_key) else []))
if not md:
ends = [_iso(_record(item).get(end_key)) for item in periods]
last = max((end for end in ends if end), default=None)
if last and at >= last:
# The producer lists one cycle; the next cycle is not computed.
row["status"] = "not_available_yet"
row["reason"] = "first_cycle_ended_next_cycle_not_computed"
row["cycle_end"] = last
else:
row["status"] = "no_period_at_reference_date"
return row
row["md"] = _period(md, chart, modules, start_key=start_key, end_key=end_key, tz=tz)
ad = _running(list(md.get(child_key) or []), at, start_key, end_key)
if ad:
row["ad"] = _period(ad, chart, modules, start_key=start_key, end_key=end_key, tz=tz)
pd = _running(list(ad.get(grandchild_key) or []), at, start_key, end_key)
if pd:
row["pd"] = _period(pd, chart, modules, start_key=start_key, end_key=end_key, tz=tz)
return row
def _attach_continuity(row: dict, checks: list[dict]) -> None:
"""Every check on the row; `continuity_issue` (card) only when one is not continuous."""
checks = [check for check in checks if check.get("segments")]
if not checks:
return
row["continuity"] = [{k: check.get(k) for k in ("table", "status", "resolution", "gaps", "overlaps", "reason")
if check.get(k) not in (None, [])} for check in checks]
issues = [entry for entry in row["continuity"] if entry.get("status") != "continuous"]
if issues:
row["continuity_issue"] = issues
# 产品 2026-11-07: dashas the upstream technique layer now computes (BUG-1704) replace
# the dasha table's not_available_yet rows. Age-based periods are dated like Narayana.
UPSTREAM_DASHA_SECTIONS = {
"naisargika": "naisargika_dasha", "kaala": "kaala_dasha", "lagnamsaka": "lagnamsaka_dasha",
"yogardha": "yogardha_dasha", "padanadhamsa": "padanadhamsa_dasha", "karaka": "karaka_dasha",
"tara": "tara_dasha", "mandooka": "mandooka_dasha", "paryaaya": "paryaaya_dasha",
}
def merge_upstream_dashas(layers: dict, chart: dict, modules: dict, *, birth_payload: dict, reference: datetime) -> None:
table = _record(layers.get("dasha_systems"))
sections = _record(_record(layers.get("upstream_techniques")).get("sections"))
rows = table.get("rows")
if not isinstance(rows, list) or not sections:
return
birth = _birth_datetime(birth_payload)
zone = _birth_zone(birth_payload)
at = reference.strftime("%Y-%m-%d")
by_system = {row.get("system"): index for index, row in enumerate(rows) if isinstance(row, dict)}
for system, key in UPSTREAM_DASHA_SECTIONS.items():
section = _record(sections.get(key))
if not section:
continue
status = section.get("evidence_status") or section.get("status")
periods = section.get("periods") if isinstance(section.get("periods"), list) else []
if periods:
dated = []
for item in periods:
item = dict(_record(item))
if "start" not in item and isinstance(item.get("start_age"), (int, float)):
item.update(_narayana_dates(birth, item, zone))
# Full instants for the period check; the dates above stay for display.
item["start"], item["end"] = item.pop("start_at", item["start"]), item.pop("end_at", item.get("end"))
dated.append(item)
row = _timeline_row(system, dated, chart, modules, at, source="upstream_technique_layers", status_note=status,
tz=zone)
else:
row = {"system": system, "applicable": False, "status": section.get("status") or "blocked",
"reason": section.get("reason") or "; ".join(str(item) for item in (section.get("blockers") or [])) or None,
"source": "upstream_technique_layers"}
if system in by_system:
rows[by_system[system]] = row
else:
rows.append(row)
# Sudasa (Sree Lagna, PVR 5.7; research_timing.families.sudasa_birth): research tier.
try:
try:
from research_timing.families import sudasa_birth
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.research_timing.families import sudasa_birth
lons = {name: lon for name, pdata in _record(chart.get("planets")).items()
if (lon := _planet_lon(pdata)) is not None}
asc = _planet_lon(chart.get("ascendant"))
sudasa = sudasa_birth(float(asc), lons, birth.replace(tzinfo=zone), 365.25)
# 原值:start / end 截成日期;新值:保留完整时刻(_period 另给日期);原因:占星师第三轮审查 2026-11-07
periods = [dict(_record(item), start=str(_record(item).get("start")), end=str(_record(item).get("end")),
antardashas=[dict(_record(c), start=str(_record(c).get("start")), end=str(_record(c).get("end")))
for c in (_record(item).get("antardashas") or [])])
for item in sudasa.get("periods") or []]
row = _timeline_row("sudasa", periods, chart, modules, at, child_key="antardashas",
source="research_timing.families.sudasa_birth", status_note="parameter_sensitive", tz=zone)
except (KeyError, TypeError, ValueError, ImportError) as exc:
row = {"system": "sudasa", "applicable": False, "status": "blocked", "reason": str(exc)}
if "sudasa" in by_system:
rows[by_system["sudasa"]] = row
else:
rows.append(row)
def _narayana_dates(birth: datetime, row: dict, tz: timezone | None = None) -> dict:
"""Dates of one Narayana row: the engine's own (unrounded) dates when present.
Rows straight from `narayana_rath` carry unrounded ages and are converted
with the same rule as `narayana_legacy_label.age_to_date`. `start_at` /
`end_at` keep the full instant (占星师第三轮审查 2026-11-07).
"""
out: dict[str, Any] = {}
for key, date_key, name in (("start_age", "start_date", "start"), ("end_age", "end_date", "end")):
if isinstance(row.get(date_key), str):
out[name] = row[date_key][:10]
full = _stamp(row[date_key], tz)
if full:
out[f"{name}_at"] = full
continue
age = row.get(key)
if isinstance(age, (int, float)):
moment = birth + timedelta(days=float(age) * 365.25)
out[name] = moment.strftime("%Y-%m-%d")
out[f"{name}_at"] = (moment.replace(tzinfo=tz) if tz else moment).isoformat(timespec="seconds")
return out
def build_dasha_systems(chart: dict, modules: dict, *, birth_payload: dict, reference: datetime) -> dict:
"""The running MD / AD / PD of every dasha system an engine module computes.
Producers: `modules.dasha_sub_periods` (Vimshottari), `modules.narayana_dasha`
(Sanjay Rath, product default), `rashi_dashas_native.calculate_rashi_dashas`
(Chara K.N. Rao, Sthira, Drig, Shoola, Niryaana Shoola, Navamsha, Lagna
Kendradi), `dasha_registry.compute_dasha` (Yogini, Kalachakra), the
applicability-gated nakshatra dashas (`ashtottari_dasha` strict BPHS gate,
the conditional set) and `tribhagi_dasha` (MD only). A system without an
engine module is listed as not_available_yet.
"""
_scripts_on_path()
import dasha_registry as registry
import rashi_dashas_native as rashi
at = reference.strftime("%Y-%m-%d")
birth = _birth_datetime(birth_payload)
zone = _birth_zone(birth_payload)
rows: list[dict] = []
sub = _record(modules.get("dasha_sub_periods"))
current = _record(sub.get("current"))
vim: dict[str, Any] = {"system": "vimshottari", "applicable": True}
for level, value in (("md", current.get("mahadasha")), ("ad", current.get("antardasha")),
("pd", _record(sub.get("pratyantar_dasha_timeline")).get("current"))):
period = _period(_record(value) or None, chart, modules, tz=zone)
if period:
vim[level] = period
timeline = sub.get("timeline") if isinstance(sub.get("timeline"), list) else None
if timeline:
_attach_continuity(vim, [_sequence_check(timeline, "start", "end", zone, "vimshottari.md")])
rows.append(vim)
narayana = _record(modules.get("narayana_dasha"))
nar_current = _record(narayana.get("current_dasha"))
nar: dict[str, Any] = {"system": "narayana", "applicable": narayana.get("status") != "blocked",
"profile": narayana.get("profile"), "date_basis": NARAYANA_DATE_BASIS}
for level in ("md", "ad", "pd"):
item = _record(nar_current.get(level))
if item:
period = _period(item, chart, modules, tz=zone) or {}
period.pop("lord_houses", None)
if isinstance(item.get("lord"), str):
houses = _lord_houses(chart, modules, item["lord"])
if houses:
period["lord_houses"] = houses
period.update(_narayana_dates(birth, item, zone))
# 占星师 2026-11-08 验收清单 §7 反例「第二轮大运」/「0 年大运」:卡上要能说出
# 这是第几轮、哪一行是 0 年段;值一律照抄引擎行,不在这里重判(BUG-1812)。
for key in ("cycle_index", "zero_duration"):
if item.get(key) is not None:
period[key] = item[key]
nar[level] = period
# 占星师 2026-11-08 验收清单 §7 反例「小运未决但大运已定」:小运未决必须被点名,
# 不能只表现为「没有 ad」(「没算」与「未决」分开;BUG-1812)。
for key in ("ad_status", "ad_reason"):
if nar_current.get(key) is not None:
nar[key] = nar_current[key]
# 产品 2026-11-07: the next Narayana AD with its dates (Rath equal ADs, narayana_rath.antardasha).
md_item = _record(nar_current.get("md"))
ad_item = _record(nar_current.get("ad"))
if md_item.get("sign") in SIGNS and isinstance(ad_item.get("start_age"), (int, float)):
try:
from narayana_rath import antardasha as _rath_antardasha
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.narayana_rath import antardasha as _rath_antardasha
lons = {name: lon for name, pdata in _record(chart.get("planets")).items()
if (lon := _planet_lon(pdata)) is not None}
try:
ads = _record(_rath_antardasha({**md_item, "sign_idx": SIGNS.index(md_item["sign"])}, lons)).get("rows") or []
except (KeyError, TypeError, ValueError) as exc:
ads = []
nar["next_ad_status"] = f"blocked: {exc}"
# One computation for both: the running AD's dates are re-read from the same Rath rows,
# so the running and next AD meet without a gap (the engine's current AD ages are
# rounded to 0.01 year, which left a two-day hole; astrologer review 2026-11-07).
ordered = sorted((row for row in ads if isinstance(row, dict) and isinstance(row.get("start_age"), (int, float))),
key=lambda row: row["start_age"])
current_row = next((row for row in ordered if row.get("sign") == ad_item.get("sign")
and abs(float(row["start_age"]) - float(ad_item["start_age"])) < 0.02), None)
if current_row is not None and isinstance(nar.get("ad"), dict):
nar["ad"].update(_narayana_dates(birth, current_row, zone))
later = [row for row in ordered if row["start_age"] > float((current_row or ad_item)["start_age"]) + 1e-6]
if later:
nxt = later[0]
period = _period(nxt, chart, modules, tz=zone) or {}
period.pop("lord_houses", None)
if isinstance(nxt.get("lord"), str):
houses = _lord_houses(chart, modules, nxt["lord"])
if houses:
period["lord_houses"] = houses
period.update(_narayana_dates(birth, nxt, zone))
nar["next_ad"] = period
# 占星师第三轮审查 2026-11-07: the whole AD row set of the running MD, on full
# instants (start inclusive, end exclusive), replaces the running/next date compare.
if ordered:
dated = [{"start": dates.get("start_at"), "end": dates.get("end_at")}
for dates in (_narayana_dates(birth, row, zone) for row in ordered)]
_attach_continuity(nar, [_sequence_check(dated, "start", "end", zone, "narayana.running_md.ad")])
if narayana.get("status") == "blocked":
nar["status"] = "blocked"
nar["reason"] = narayana.get("blocked_note") or narayana.get("reason")
rows.append(nar)
planet_lons = {name: lon for name, pdata in _record(chart.get("planets")).items()
if (lon := _planet_lon(pdata)) is not None}
asc_lon = _planet_lon(chart.get("ascendant"))
try:
sections = rashi.calculate_rashi_dashas(birth.date(), float(asc_lon), planet_lons)
except Exception as exc: # each system is reported on its own
sections = {name: _blocked(exc) for name in RASHI_DASHA_ORDER}
for name in RASHI_DASHA_ORDER:
section = _record(sections.get(name))
if section.get("status") == "blocked" or not isinstance(section.get("periods"), list):
rows.append({"system": name, "applicable": False, "status": "blocked",
"reason": section.get("reason") or "periods_missing"})
continue
extra: dict[str, Any] = {"profile": section.get("profile_id")}
for key in ("year_rule_status", "confidence_status"):
if section.get(key):
extra[key] = section[key]
rows.append(_timeline_row(name, section["periods"], chart, modules, at, tz=zone, **extra))
body = {**birth_payload, "today": at}
for key in ("yogini", "kalachakra"):
try:
periods = registry.compute_dasha(key, birth_dt=birth, planets=_record(chart.get("planets")),
ascendant=_record(chart.get("ascendant")), body=body)["periods"]
rows.append(_timeline_row(key, periods, chart, modules, at, start_key="start_date", end_key="end_date",
lord_key=None, tz=zone))
except Exception as exc:
rows.append({"system": key, "applicable": False, **_blocked(exc)})
_alt, conditional, _lons = registry.build_reading_birth_info(
birth, _record(chart.get("planets")), _record(chart.get("ascendant")), {**body, "as_of": at},
)
def gated(key: str, payload: dict) -> dict:
applicable = payload.get("applicable") is True
row: dict[str, Any] = {"system": key, "applicable": applicable}
rule = payload.get("applicability_rule_family") or payload.get("rule_family")
if rule:
row["rule"] = rule
if not applicable:
row["status"] = payload.get("execution_status") or "not_applicable"
if payload.get("reason"):
row["reason"] = payload["reason"]
return row
periods = registry._major_rows(payload)
timeline = _timeline_row(key, periods, chart, modules, at, start_key="start_date", end_key="end_date", tz=zone)
timeline.update({k: v for k, v in row.items() if k not in timeline})
if payload.get("confidence_status"):
timeline["confidence_status"] = payload["confidence_status"]
return timeline
try:
ashtottari = registry._load("ashtottari_dasha").calculate_ashtottari_dasha({
**conditional, "applicability_rule_family": "bphs_rahu_from_lagna_lord_excluding_lagna_v1", # BUG-1676 second ruling (9cfc823e)
})
ashtottari_row = gated("ashtottari", _record(ashtottari))
ashtottari_row.setdefault("rule", "bphs_rahu_from_lagna_lord_excluding_lagna_v1")
rows.append(ashtottari_row)
except Exception as exc:
rows.append({"system": "ashtottari", "applicable": False, **_blocked(exc)})
try:
tribhagi = _record(registry._load("tribhagi_dasha").calculate_tribhagi_dasha(dict(conditional)))
row = gated("tribhagi", tribhagi)
row.pop("ad", None)
row.pop("pd", None)
if tribhagi.get("period_depth_status"):
row["depth_status"] = tribhagi["period_depth_status"]
rows.append(row)
except Exception as exc:
rows.append({"system": "tribhagi", "applicable": False, **_blocked(exc)})
for key in CONDITIONAL_DASHAS:
try:
if key == "shasti_hayani":
birth_info = {**conditional, "child_period_profile": registry.PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE}
payload = registry._load("shastihayani_dasha").calculate_shastihayani_dasha(birth_info)
else:
payload = registry._conditional_payload(key, conditional)
rows.append(gated(key, _record(payload)))
except Exception as exc:
rows.append({"system": key, "applicable": False, **_blocked(exc)})
for key in DASHA_NOT_IMPLEMENTED:
rows.append({"system": key, "status": "not_available_yet"})
return {
"status": "executed",
"source": "dasha_sub_periods, narayana_dasha, rashi_dashas_native, dasha_registry, ashtottari / tribhagi / conditional dasha modules",
"reference_date": at,
"house_rule": "house: whole-sign house of the period sign from the natal lagna; lord_houses: the lord's natal house (in) and the houses it rules (owns)",
# 占星师第三轮审查 2026-11-07: one boundary rule; full instants beside the display dates.
"boundary_rule": "start_inclusive_end_exclusive",
"time_basis": f"start_at / end_at: producer instant; naive producer times are the birth clock at UTC{_fmt_tz(zone)}",
"rows": rows,
}
def build_kp_placidus(engine, birth_payload: dict, chart: dict, modules: dict) -> dict:
"""`jyotish_engine.cmd_kp`: KP ayanamsa, Swiss Ephemeris Placidus cusps, cusp-to-cusp houses.
Adds the whole-sign comparison: each cusp's sign beside the whole-sign
house sign of the same number, and each planet's KP house (significator B)
beside its whole-sign house.
"""
from types import SimpleNamespace
args = SimpleNamespace(
year=int(birth_payload["year"]), month=int(birth_payload["month"]), day=int(birth_payload["day"]),
hour=int(birth_payload.get("hour") or 0), minute=int(birth_payload.get("minute") or 0),
second=int(float(birth_payload.get("second") or 0)), lat=float(birth_payload["lat"]),
lon=float(birth_payload["lon"]), tz=float(birth_payload["tz"]),
node_mode=str(birth_payload.get("node_mode") or "mean"),
ayanamsa=str(birth_payload.get("ayanamsa") or "raman"),
)
result = _record(engine.cmd_kp(args))
if result.get("error"):
return _blocked(str(result["error"]))
profile = _record(result.get("kp_profile"))
lagna_idx = _lagna_idx(chart)
houses: dict[str, Any] = {}
for number, row in _record(result.get("houses")).items():
item = _record(row)
lords = _record(item.get("kp_lords"))
entry = {
"sign": item.get("sign"),
"cusp_longitude": item.get("cusp_longitude"),
**{key: lords.get(key) for key in KP_CUSP_KEYS},
"significators": item.get("significators"),
}
if lagna_idx is not None:
whole = SIGNS[(lagna_idx + int(number) - 1) % 12]
entry["whole_sign_sign"] = whole
entry["same_as_whole_sign"] = whole == item.get("sign")
houses[str(number)] = entry
planets: dict[str, Any] = {}
for name, row in _record(result.get("planets")).items():
item = _record(row)
lords = _record(item.get("kp_lords"))
significators = _record(item.get("significators"))
whole = _record(_record(chart.get("planets")).get(name)).get("house")
entry = {**{key: lords.get(key) for key in KP_CUSP_KEYS}, "significators": significators}
if isinstance(significators.get("B"), int) and isinstance(whole, int):
entry["kp_house"] = significators["B"]
entry["whole_sign_house"] = whole
planets[name] = entry
sub = _record(_record(modules.get("dasha_sub_periods")))
current = _record(sub.get("current"))
lords = {
"md": _record(current.get("mahadasha")).get("lord"),
"ad": _record(current.get("antardasha")).get("lord"),
"pd": _record(_record(sub.get("pratyantar_dasha_timeline")).get("current")).get("lord"),
}
triggers: dict[str, Any] = {}
for level, lord in lords.items():
if not isinstance(lord, str):
continue
signified = sorted(
int(number) for number, row in houses.items()
if any(
(lord in value) if isinstance(value, list) else value == lord
for value in _record(row.get("significators")).values()
)
)
triggers[level] = {"lord": lord, "signifies_houses": signified}
ruling = _record(result.get("ruling_planets"))
return {
"status": profile.get("status") or "parameter_sensitive",
"source": "jyotish_engine.cmd_kp (KP ayanamsa, Swiss Ephemeris Placidus cusps, cusp-to-cusp houses)",
"ayanamsa": profile.get("ayanamsa"),
"house_system": profile.get("house_system"),
"house_basis": result.get("house_basis"),
"house_occupancy_basis": result.get("house_occupancy_basis"),
"houses": houses,
"planets": planets,
"vimshottari_lords_signify": triggers,
"ruling_planets": {key: ruling.get(key) for key in (
"day_lord", "moon_sign_lord", "moon_star_lord", "moon_sub_lord",
"ascendant_sign_lord", "ascendant_star_lord", "ascendant_sub_lord",
) if ruling.get(key)},
"boundary": profile.get("boundary") or _record(result.get("maturity_profile")).get("claim_boundary"),
}
def build_special_points(chart: dict, birth_payload: dict) -> dict:
"""`chart_view_points.api.compute_chart_points` (the chart page's producer), selected rows only."""
_scripts_on_path()
from chart_view_points.api import compute_chart_points
body = {
**{key: birth_payload.get(key) for key in ("year", "month", "day", "hour", "minute", "second", "lat", "lon", "tz")},
"second": int(float(birth_payload.get("second") or 0)),
"tz": float(birth_payload["tz"]),
"ayanamsa": birth_payload.get("ayanamsa") or "raman",
"node_mode": birth_payload.get("node_mode") or "mean",
"planets": _record(chart.get("planets")),
"ascendant": _record(chart.get("ascendant")),
}
packet = compute_chart_points(body)
points: dict[str, Any] = {}
blocks: dict[str, str] = {}
for block, keys in SPECIAL_POINT_KEYS.items():
part = _record(packet.get(block))
if part.get("status") not in ("computed", "executed"):
blocks[block] = str(part.get("reason") or part.get("status") or "missing")
continue
blocks[block] = "computed"
for row in part.get("rows") or []:
item = _record(row)
if item.get("key") in keys:
points[item["key"]] = {key: item[key] for key in SPECIAL_POINT_ROW_KEYS if item.get(key) is not None}
goel = _record(packet.get("goel_points"))
if goel.get("status") == "computed":
blocks["goel_points"] = "computed"
for row in goel.get("rows") or []:
item = _record(row)
if item.get("point"):
points[str(item["point"])] = {
key: item[key] for key in ("planet", "sign", "house", "status", "rule") if item.get(key) is not None
}
else:
blocks["goel_points"] = str(goel.get("reason") or "missing")
sahams = _record(packet.get("sahams"))
return {
"status": "executed",
"source": "chart_view_points.api.compute_chart_points (upagrahas, special lagnas, Yogi, sensitive points, Sahams, Goel points)",
"blocks": blocks,
"points": points,
"saham_day_night": {key: sahams.get(key) for key in ("is_daytime", "daynight_method") if sahams.get(key) is not None},
}
def build_transit_detail(engine, chart: dict, modules: dict, *, reference: datetime, tz: float, node_mode: str) -> dict:
"""Jupiter / Saturn / Rahu / Ketu and the Moon on the reference date.
Positions: `_calc_sidereal_planets_for_jd` at local noon (as slow_transits).
Nakshatra: `jyotish_engine._nakshatra_fields`. Tara Bala and Chandra Bala:
`nakshatra_advanced.calc_tara_bala` / `calc_chandra_bala` from the natal
Moon. Sade Sati and Kantaka Shani: `chart.sade_sati` (true Saturn transit).
"""
_scripts_on_path()
from nakshatra_advanced import calc_chandra_bala, calc_tara_bala
positions = _transit_positions(engine, reference, tz, node_mode)
lagna_idx = _lagna_idx(chart)
moon = _record(_record(chart.get("planets")).get("Moon"))
moon_lon = _planet_lon(moon)
moon_idx = _sign_index(moon.get("sign"))
moon_nak = int(moon_lon / (360 / 27)) % 27 if moon_lon is not None else None
bodies: dict[str, Any] = {}
for body in (*SLOW_BODIES, "Moon"):
row = _record(positions.get(body))
lon = row.get("degree_raw")
if not row.get("sign") or not isinstance(lon, (int, float)):
continue
entry: dict[str, Any] = {
"sign": row["sign"],
"degree_in_sign": row.get("degree_in_sign"),
"retrograde": row.get("retrograde"),
"house_from_lagna": _whole_sign_house(row["sign"], lagna_idx),
"house_from_moon": _whole_sign_house(row["sign"], moon_idx),
**engine._nakshatra_fields(float(lon)),
}
if moon_nak is not None:
tara = calc_tara_bala(moon_nak, int(float(lon) / (360 / 27)) % 27)
entry["tara_bala"] = {"tara": tara.get("tara_name"), "quality": tara.get("quality")}
if body == "Moon" and moon_idx is not None and _sign_index(row["sign"]) is not None:
chandra = calc_chandra_bala(moon_idx, _sign_index(row["sign"]))
entry["chandra_bala"] = {"from_moon": chandra.get("rashi_from_moon"), "name": chandra.get("chandra_name"),
"quality": chandra.get("quality")}
bodies[body] = entry
sade = _record(chart.get("sade_sati"))
sade_row = _record(sade.get("sade_sati"))
kantaka = _record(sade.get("kantaka_shani"))
return {
"status": "executed",
"source": "jyotish_engine._calc_sidereal_planets_for_jd, _nakshatra_fields; nakshatra_advanced.calc_tara_bala / calc_chandra_bala; chart.sade_sati",
"reference_date": reference.strftime("%Y-%m-%d"),
"bodies": bodies,
"sade_sati": {key: sade_row.get(key) for key in ("active", "phase", "phase_name") if sade_row.get(key) is not None},
"kantaka_shani": {"active": kantaka.get("active")} if "active" in kantaka else {},
"sade_sati_reference_date": _record(sade.get("provenance")).get("reference_date"),
}
YOGA_CONDITION_ROW_CAP = 30
def _yoga_rows_within_cap(rows: list[dict], cap: int = YOGA_CONDITION_ROW_CAP) -> list[dict]:
"""The first `cap` rows; rows with self-reference witnesses (`formation_evidence`) are never cut.
占星师 2026-11-08 第六轮 R3(BUG-1786 补):主检测器行(Neecha Bhanga)排在 ungoverned 末尾,
原来的 `rows[:30]` 在公开盘上把它截掉,自参照字段到不了卡。保留它们时,从末尾让出同样多的普通行,
行数上限不变。
"""
if len(rows) <= cap:
return rows
keep = [row for row in rows[cap:] if row.get("formation_evidence")]
if not keep:
return rows[:cap]
head = rows[:cap]
drop = len(keep)
out: list[dict] = []
for row in reversed(head):
if drop and not row.get("formation_evidence"):
drop -= 1
continue
out.append(row)
return list(reversed(out)) + keep
def build_yoga_conditions(chart: dict, modules: dict, layers: dict) -> dict:
"""Each formed yoga with what the engine knows about it.
Rows: `raman_support.yogas` hits (packet rules, support-only candidates)
and `ungoverned_detected` (detector rows, computed, not governed), plus the
detector description from `chart.yogas`. Per planet: the houses it rules
(functional layer), its functional role, natal dignity, combustion
(graha_drishti layer), and whether it is a running Vimshottari lord.
"""
sieve = _record(_record(modules.get("raman_support")).get("yogas"))
if sieve.get("status") != "executed":
return _blocked("raman_support_yogas_missing")
functional = _record(modules.get("functional_benefic_malefic"))
owned = _owned_houses(modules)
role_lists = {
"yogakaraka": functional.get("yogakarakas") or [],
"benefic": functional.get("functional_benefics") or [],
"malefic": functional.get("functional_malefics") or [],
"neutral": functional.get("functional_neutrals") or [],
}
drishti = _record(_record(layers.get("graha_drishti")).get("planets"))
sub = _record(modules.get("dasha_sub_periods"))
current = _record(sub.get("current"))
running = {
"md": _record(current.get("mahadasha")).get("lord"),
"ad": _record(current.get("antardasha")).get("lord"),
"pd": _record(_record(sub.get("pratyantar_dasha_timeline")).get("current")).get("lord"),
}
descriptions: dict[str, str] = {}
for item in chart.get("yogas") or []:
row = _record(item)
if isinstance(row.get("name"), str) and isinstance(row.get("desc"), str):
descriptions.setdefault(row["name"], row["desc"])
rows: list[dict] = []
seen: set[str] = set()
for source, status_default in (("hits", "candidate"), ("ungoverned_detected", "computed")):
for item in sieve.get(source) or []:
row = _record(item)
name = row.get("name")
if not isinstance(name, str) or name in seen or row.get("hit") is False:
continue
seen.add(name)
planets = [p for p in row.get("planets") or [] if isinstance(p, str)]
# 原值:status 只有 candidate / computed
# 新值:structural_observation 规则行的 status 写 structural_observation
# 原因:产品 2026-11-07 同步占星师 Yoga 规则批次(上游 733a931b);结构观察不是命名 Yoga。
observation = row.get("claim_kind") == "structural_observation"
entry: dict[str, Any] = {
"name": name,
"category": row.get("category"),
"status": "structural_observation" if observation else status_default,
}
if row.get("claim_kind"):
entry["claim_kind"] = row["claim_kind"]
if row.get("claim_status"):
entry["claim_status"] = row["claim_status"]
for key in ("formation", "formation_checks"):
if row.get(key):
entry[key] = row[key]
evidence = _record(row.get("formation_evidence"))
if evidence.get("cancellation_reasons"):
# 占星师 2026-11-08 第六轮 R3:自参照分支照列,但标明不能作为额外独立佐证。
entry["formation_evidence"] = {
"self_reference_only": bool(evidence.get("self_reference_only")),
"independent_condition_count": evidence.get("independent_condition_count"),
"cancellation_reasons": [
{key: reason[key] for key in ("condition", "planet", "reference", "relative_house",
"self_reference", "independent_confirmation_allowed")
if key in reason}
for reason in (_record(item) for item in evidence["cancellation_reasons"])],
}
condition = descriptions.get(name)
conditions = [str(x) for x in row.get("conditions") or [] if x]
if condition:
entry["condition"] = condition
if conditions:
entry["conditions_met"] = conditions
reductions = [str(x) for x in row.get("reductions") or [] if x]
if reductions:
entry["reductions"] = reductions
if not condition and not conditions:
entry["condition"] = "not_available_yet"
if planets:
detail: dict[str, Any] = {}
for planet in planets:
natal = _record(_record(chart.get("planets")).get(planet))
role = next((label for label, names in role_lists.items() if planet in names), None)
pdetail: dict[str, Any] = {}
if isinstance(owned.get(planet), list):
pdetail["owns"] = list(owned[planet])
if role:
pdetail["functional"] = role
if natal.get("status"):
pdetail["dignity"] = natal["status"]
if _record(drishti.get(planet)).get("combust") is True:
pdetail["combust"] = True
detail[planet] = pdetail
entry["planets"] = detail
active = [level for level, lord in running.items() if lord in planets]
entry["running_dasha_levels"] = active
rows.append(entry)
# 占星师第三轮审查 2026-11-07: packet rules the engine could not evaluate
# are listed apart; they are neither formed nor ruled out.
not_evaluated = [
{key: _record(item).get(key) for key in ("name", "rule_id", "evaluation_status", "error_class")}
for item in sieve.get("not_evaluated") or [] if isinstance(_record(item).get("name"), str)
]
evaluation = _record(_record(modules.get("yogas")).get("rule_engine_evaluation"))
failures = [
{key: _record(item).get(key) for key in ("name", "rule_id", "error_class")}
for item in evaluation.get("evaluation_failures") or [] if isinstance(item, dict)
]
out: dict[str, Any] = {
"status": "executed",
"source": "raman_support.yogas (hits = packet candidates; ungoverned_detected = detector rows) + chart.yogas desc + functional / graha_drishti layers",
"status_rule": ("candidate: a governed packet rule matched (support only); computed: a detector found it, not governed; "
"structural_observation: a recorded lord association, placement or dignity, not a named Yoga, no effect is read from it; "
"none is confirmed"),
# 产品 2026-11-07 同步占星师 Yoga 规则批次(上游 1307bf69).
"formation_rule": ("formation_checks: the source conditions the engine executed (passed true / false / null = not checked); "
"formation.status formed_under_source = the source definition holds, strength and outcome not assessed; "
"candidate = conditions not reviewed or incomplete; conditions_not_met = a required condition failed"),
"running_vimshottari": {k: v for k, v in running.items() if v},
"rows": _yoga_rows_within_cap(rows),
}
if not_evaluated or failures or evaluation.get("evaluation_status") == "evaluation_failed":
out["not_evaluated"] = not_evaluated + [row for row in failures
if row.get("rule_id") not in {r.get("rule_id") for r in not_evaluated}]
out["not_evaluated_rule"] = "evaluation_failed / not_implemented: the rule could not be checked; never read as not formed"
if evaluation.get("evaluation_status") == "evaluation_failed":
out["rule_engine_status"] = {"status": "evaluation_failed", "error_class": evaluation.get("error_class")}
return out
def build_strength_detail(chart: dict, modules: dict) -> dict:
"""Avastha (`deep_varga_avastha._build_avastha_summary`) and Bhava Bala (`bhava_bala.bhava_bala_from_chart`)."""
_scripts_on_path()
out: dict[str, Any] = {"status": "executed"}
planets = _record(chart.get("planets"))
asc = _record(chart.get("ascendant"))
lons = {name: lon for name, pdata in planets.items() if (lon := _planet_lon(pdata)) is not None}
try:
from deep_varga_avastha import _build_avastha_summary
summary = _build_avastha_summary(lons, float(_planet_lon(asc) or 0.0))
states: dict[str, Any] = {}
for name, row in _record(summary.get("planet_states")).items():
item = _record(row)
states[name] = {
key: _record(item.get(key)).get("state")
for key in ("Bala", "Jagrat", "Deeptadi")
if _record(item.get(key)).get("state")
}
out["avastha"] = {"source": "deep_varga_avastha._build_avastha_summary", "planets": states}
except Exception as exc:
out["avastha"] = _blocked(exc)
try:
from bhava_bala import bhava_bala_from_chart
shadbala = _record(_record(modules.get("shadbala")).get("planets"))
result = bhava_bala_from_chart(planets, asc.get("sign"), float(asc.get("degree_in_sign") or 0.0), shadbala)
out["bhava_bala"] = {
"source": "bhava_bala.bhava_bala_from_chart",
"method": result.get("method_boundary") or result.get("method"),
"houses": {
str(_record(row).get("house")): _record(row).get("total_rupas")
for row in result.get("houses") or [] if _record(row).get("house")
},
"unit": "rupas",
}
except Exception as exc:
out["bhava_bala"] = _blocked(exc)
return out
def build_card_parameters(chart: dict, modules: dict, layers: dict) -> dict:
"""The calculation parameters each value on the card depends on, as the producers report them."""
_scripts_on_path()
birth = _record(chart.get("birth"))
contract = _record(_record(chart.get("calculation_contract")).get("effective"))
params: dict[str, Any] = {
"ayanamsa": birth.get("ayanamsa_name") or contract.get("ayanamsa"),
"ayanamsa_degrees": birth.get("ayanamsa"),
"node_mode": birth.get("node_mode") or contract.get("node_mode"),
"d1_house_system": "whole_sign",
}
missing: list[str] = []
kp = _record(layers.get("kp_placidus"))
if kp.get("house_system"):
params["kp"] = {"ayanamsa": kp.get("ayanamsa"), "house_system": kp.get("house_system"),
"house_occupancy": kp.get("house_occupancy_basis")}
else:
missing.append("kp")
karakas = _record(_record(modules.get("jaimini")).get("chara_karakas"))
short = [key for key in ("AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK") if key in karakas]
params["chara_karaka_scheme"] = len(short) if short else None
try:
import varga
methods = getattr(varga, "VARGA_METHODS", {})
params["varga_methods"] = {f"D{div}": methods[div] for div in (3, 24, 30, 60) if div in methods}
except Exception:
missing.append("varga_methods")
narayana = _record(modules.get("narayana_dasha"))
params["narayana"] = {
key: narayana.get(key) for key in ("profile", "dual_lord_rule", "antardasha_start_rule", "direction")
if narayana.get(key)
}
legacy = _record(narayana.get("legacy"))
if legacy.get("profile"):
params["narayana"]["comparison"] = legacy.get("profile")
systems = {row.get("system"): row for row in _record(layers.get("dasha_systems")).get("rows") or [] if isinstance(row, dict)}
for name in ("chara", "sthira", "drig", "shoola", "niryaana_shoola", "navamsha", "lagna_kendradi"):
row = _record(systems.get(name))
if row.get("profile"):
params.setdefault("rashi_dasha_profiles", {})[name] = row["profile"]
if _record(systems.get("drig")).get("year_rule_status"):
params["drig_year_rule_status"] = systems["drig"]["year_rule_status"]
ashtottari = _record(systems.get("ashtottari"))
params["ashtottari"] = {key: ashtottari.get(key) for key in ("applicable", "rule", "reason") if ashtottari.get(key) is not None}
vivah = _record(layers.get("vivah_saham"))
day_night = _record(layers.get("day_night"))
special = _record(layers.get("special_points"))
params["saham"] = {
"day_night": "day" if day_night.get("is_day") is True else ("night" if day_night.get("is_day") is False else None),
"formula_rule": "tajika._select_saham_formula: each Saham rule has formula_day and formula_night (PVR §28.8.1 Table 74); the birth's day / night picks one",
**_record(special.get("saham_day_night")),
}
if vivah.get("formula_variant"):
params["saham"]["vivaha_formula_variant"] = vivah["formula_variant"]
try:
from tajika_kernel import DEEPTAMSA
params["tajika_deeptamsa"] = {"orbs": dict(DEEPTAMSA), "rule": "aspect orb = average of the two planets' deeptamsa (tajika_kernel)"}
except Exception:
missing.append("tajika_deeptamsa")
gulika = _record(modules.get("gulika"))
if gulika.get("method"):
params["gulika_method"] = gulika.get("method")
if not params.get("node_mode"):
missing.append("node_mode")
if params.get("chara_karaka_scheme") is None:
missing.append("chara_karaka_scheme")
return {"status": "executed", "parameters": params, "missing": missing}
def _declared_routes(body: dict) -> set[str]:
values: list[str] = []
for key in ("strict_workflow_route", "theme", "themes"):
raw = body.get(key)
if isinstance(raw, str) and raw.strip():
values.append(raw.strip().lower())
elif isinstance(raw, list):
values.extend(str(item).strip().lower() for item in raw if item)
return {item for item in values if item}
def build_upstream_techniques(engine, chart: dict, **kwargs) -> dict:
_scripts_on_path()
try:
from scripts.upstream_technique_layers import build_upstream_techniques as build
except ModuleNotFoundError: # pragma: no cover - script execution
from upstream_technique_layers import build_upstream_techniques as build
return build(engine, chart, **kwargs)
def _guard(builder, *args, **kwargs) -> dict:
try:
result = builder(*args, **kwargs)
return result if isinstance(result, dict) else _blocked("producer_returned_non_dict")
except Exception as exc: # native producers stay isolated from the workflow
return _blocked(exc)
def attach_native_consultation_layers(chart: dict, *, birth_payload: dict, body: dict,
reference_dt: datetime) -> dict:
"""Write `chart.consultation_native_layers`. Safe on empty charts."""
if not isinstance(chart, dict) or not chart.get("success", True):
return chart
modules = _record(chart.get("modules"))
if not _record(chart.get("planets")) or not _record(chart.get("ascendant")):
chart["consultation_native_layers"] = {"schema": SCHEMA, **_blocked("chart_positions_missing")}
return chart
reference = reference_dt.replace(tzinfo=None)
routes = _declared_routes(body)
layers: dict[str, Any] = {"schema": SCHEMA, "status": "executed"}
try:
engine = _load_engine()
_scripts_on_path()
from ayanamsa_utils import temporary_ayanamsa
from domain_calculation_service import swiss_ephemeris_lock
tz = float(birth_payload.get("tz") or 0)
ayanamsa = str(birth_payload.get("ayanamsa") or "raman")
node_mode = str(birth_payload.get("node_mode") or "mean")
with swiss_ephemeris_lock(), temporary_ayanamsa(ayanamsa, engine.swe):
positions = _guard(_transit_positions, engine, reference, tz, node_mode)
saturn_lon = _record(positions.get("Saturn")).get("degree_raw")
layers["d9_summary"] = _guard(build_d9_summary, engine, chart, modules)
layers["punarphoo"] = _guard(build_punarphoo, chart, saturn_lon if isinstance(saturn_lon, (int, float)) else None)
layers["vivah_saham"] = _guard(build_vivah_saham, engine, chart, modules)
layers["day_night"] = _guard(build_day_night, modules)
layers["slow_transits"] = _guard(
build_slow_transits, engine, chart, modules,
reference=reference, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
)
layers["moon_transit"] = _guard(build_moon_transit, engine, chart, reference=reference, tz=tz, node_mode=node_mode)
layers["double_transit"] = _guard(
build_double_transit, engine, chart, modules,
birth_payload=birth_payload, reference=reference, transit_positions=positions,
)
layers["karakamsha"] = _guard(build_karakamsha, chart, modules)
layers["argala"] = _guard(build_argala, chart)
layers["dispositor_chains"] = _guard(build_dispositor_chains, engine, chart)
layers["inter_chart_linkage"] = _guard(build_inter_chart_linkage, engine, chart, modules)
layers["graha_drishti"] = _guard(build_graha_drishti, engine, chart)
layers["yoga_domains"] = _guard(build_yoga_domains, modules)
layers["traditional_yogas"] = _guard(build_traditional_yogas, modules)
# 产品 2026-11-07 占星师方案:回答模型拿到完整数据。
layers["kp_placidus"] = _guard(build_kp_placidus, engine, birth_payload, chart, modules)
layers["dasha_systems"] = _guard(build_dasha_systems, chart, modules, birth_payload=birth_payload, reference=reference)
layers["special_points"] = _guard(build_special_points, chart, birth_payload)
layers["transit_detail"] = _guard(build_transit_detail, engine, chart, modules, reference=reference, tz=tz, node_mode=node_mode)
layers["yoga_conditions"] = _guard(build_yoga_conditions, chart, modules, layers)
layers["strength_detail"] = _guard(build_strength_detail, chart, modules)
layers["card_parameters"] = _guard(build_card_parameters, chart, modules, layers)
# BUG-1699: upstream calculation fixes (affliction evidence, career
# gate, exact Saturn ingress, Chesta profiles, book rule layer).
layers["affliction_evidence"] = _guard(build_affliction_evidence, engine, chart, modules)
layers["career_analysis"] = _guard(build_career_analysis, engine, chart, modules, body)
layers["sade_sati_timeline"] = _guard(
build_sade_sati_timeline, chart, modules,
birth_payload=birth_payload, reference=reference, tz=tz, ayanamsa=ayanamsa,
)
layers["shadbala_chesta_profiles"] = _guard(build_shadbala_chesta_profiles, modules, body)
layers["book_rules"] = _guard(build_book_rules, routes)
# Techniques ported from the astrologer's upstream skill (yinduzhanxing
# 5fb8379c): Kaala / Naisargika / Padanadhamsa ... dashas, Sarvatobhadra /
# Kota / Kalanala / Latta chakras, Pachakadi, Amsayu / Pindayu.
layers["upstream_techniques"] = _guard(
build_upstream_techniques, engine, chart,
birth_payload=birth_payload, reference=reference, transit_positions=positions,
)
try:
merge_upstream_dashas(layers, chart, modules, birth_payload=birth_payload, reference=reference)
except (KeyError, TypeError, ValueError) as exc:
_record(layers.get("dasha_systems"))["upstream_merge_status"] = f"blocked: {exc}"
# 产品 2026-11-07 占星师方案第 4、5、9 条:财富等问时间的回答也要看年运;
# 年盘约 0.06 秒,每条路线都算,证据卡给非年运领域一个精简年运块。
layers["annual_tajika"] = _guard(build_annual_tajika, birth_payload, chart, reference=reference)
except Exception as exc:
layers = {"schema": SCHEMA, **_blocked(exc)}
chart["consultation_native_layers"] = layers
return chart