- 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)
2449 lines
121 KiB
Python
2449 lines
121 KiB
Python
#!/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
|