feat(synastry): store the upstream seven-item marriage match
Move the bde5d615 engine and full-synastry skill in unchanged, save both profiles without billing or a page, and retire the old synastry module. Checked targeted pytest, tsc 0, lint 0 errors, service tests 6/6, and test:db 73 pass / 13 fail matching the Windows known list. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
e7bf60c1f2
commit
cf063834ca
@@ -45,6 +45,7 @@ HEAVY_COMPUTE_PATHS = frozenset(
|
||||
"/api/vedastro/range_scan",
|
||||
"/api/vedastro_gateway/run",
|
||||
"/api/thematic_report",
|
||||
"/api/synastry_full_dossier",
|
||||
"/api/qizheng",
|
||||
"/api/western",
|
||||
"/api/ephemeris_events",
|
||||
|
||||
@@ -44,6 +44,7 @@ OFFLINE_SCRATCH_NAMES = frozenset(
|
||||
"consult_full_data_cache",
|
||||
# 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。
|
||||
"report_reference_cache",
|
||||
"synastry_full_dossier_cache",
|
||||
"ocr_extract",
|
||||
"three_engine_parity",
|
||||
"public_release_deny_patterns.txt",
|
||||
|
||||
@@ -364,7 +364,7 @@ def _planet_dignity_level(planet_name: str, sign: str) -> str:
|
||||
|
||||
|
||||
def calc_kuja_dosha(chart: Dict) -> Dict:
|
||||
"""计算完整的 Kuja Dosha(火星凶相分析)"""
|
||||
"""旧版 Kuja Dosha(五星合分)。新功能不得调用;婚恋合盘用 marriage_match。"""
|
||||
planets = chart.get("planets", {})
|
||||
|
||||
dosha_scores_high = {
|
||||
@@ -408,6 +408,7 @@ def calc_kuja_dosha(chart: Dict) -> Dict:
|
||||
|
||||
|
||||
def match_kuja_dosha(male_score: float, female_score: float) -> Dict:
|
||||
"""旧版:按性别比较火星冲克分数。新功能不得调用。"""
|
||||
diff = male_score - female_score
|
||||
if abs(diff) <= 5:
|
||||
return {"result": "good", "description": "Kuja Dosha balanced."}
|
||||
|
||||
@@ -61,7 +61,7 @@ def build() -> dict:
|
||||
"partial",
|
||||
"basic_safe_with_boundary",
|
||||
"36-point compatibility can be exposed as one factor only; not deterministic marriage outcome.",
|
||||
["references/oracle/ashtakoot_oracle_cases.json", "scripts/synastry.py"],
|
||||
["references/oracle/ashtakoot_oracle_cases.json", "scripts/marriage_match.py"],
|
||||
),
|
||||
layer(
|
||||
"mangal_dosha",
|
||||
|
||||
@@ -10505,6 +10505,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
|
||||
elif path == '/api/synastry':
|
||||
result = self._compute_synastry(body)
|
||||
self._json(result)
|
||||
elif path == '/api/marriage-match':
|
||||
match_api = _load_local_module('marriage_match_api')
|
||||
try:
|
||||
result = match_api.compute_marriage_match(body)
|
||||
except match_api.MarriageMatchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/synastry_full_dossier':
|
||||
match_api = _load_local_module('marriage_match_api')
|
||||
try:
|
||||
result = match_api.compute_synastry_full_dossier(self, body)
|
||||
except match_api.MarriageMatchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/dasha':
|
||||
result = self._compute_dasha_system(body)
|
||||
self._json(result)
|
||||
|
||||
+17
-24
@@ -16,7 +16,7 @@
|
||||
nakshatra-adv 高级Nakshatra分析(Tara Bala/Sub-Lord/兼容性)(v3.7新增)
|
||||
argala Argala门闩系统(v3.7新增)
|
||||
tajika Tajika/Varshaphala年运盘(v3.7新增)
|
||||
synastry 合盘分析(Ashta Koota 36分制+Mangal Dosha)(v3.7新增)
|
||||
marriage-match 婚恋合盘(两份盘快照;七项 35 分,火星冲克与婚姻宫压力分开列)
|
||||
full-reading 全自动综合解盘(出生信息→全链路计算→完整报告)(v3.7.1新增)
|
||||
celebrity 名人案例查询(15,807条数据+SQLite验证库)
|
||||
db-stats 验证数据库统计
|
||||
@@ -14499,20 +14499,17 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'):
|
||||
# ============================================================================
|
||||
# 21. 合盘分析(v3.7新增)
|
||||
# ============================================================================
|
||||
def cmd_synastry(args):
|
||||
def cmd_marriage_match(args):
|
||||
try:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
from synastry import calc_synastry
|
||||
except ImportError as e:
|
||||
return {"error": f"synastry模块导入失败: {e}"}
|
||||
# 构建两人数据
|
||||
p1 = {'moon_lon': args.moon1, 'gender': args.gender1 or 'M'}
|
||||
p2 = {'moon_lon': args.moon2, 'gender': args.gender2 or 'F'}
|
||||
if args.mars1 is not None: p1['mars_lon'] = args.mars1
|
||||
if args.mars2 is not None: p2['mars_lon'] = args.mars2
|
||||
if args.asc1 is not None: p1['asc_lon'] = args.asc1
|
||||
if args.asc2 is not None: p2['asc_lon'] = args.asc2
|
||||
return _build_response_envelope('synastry', calc_synastry(p1, p2), args=args, execution_status='executed')
|
||||
from marriage_match_cli import run_marriage_match_cli
|
||||
except ImportError as exc:
|
||||
return {"error": f"marriage_match模块导入失败: {exc}"}
|
||||
try:
|
||||
result = run_marriage_match_cli(args)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
return {"error": str(exc)}
|
||||
return _build_response_envelope('marriage-match', result, args=args, execution_status='executed')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -18217,16 +18214,12 @@ def main():
|
||||
p.add_argument('--hour-from-sunrise', type=float, default=6.0,
|
||||
help='从日出起算的小时数(默认6h约正午)')
|
||||
|
||||
# 21. synastry (v3.7新增)
|
||||
p = sub.add_parser('synastry', help='合盘分析(Ashta Koota 36分制)')
|
||||
p.add_argument('--moon1', type=float, required=True, help='Person1月亮黄经')
|
||||
p.add_argument('--moon2', type=float, required=True, help='Person2月亮黄经')
|
||||
p.add_argument('--mars1', type=float, default=None, help='Person1火星黄经')
|
||||
p.add_argument('--mars2', type=float, default=None, help='Person2火星黄经')
|
||||
p.add_argument('--asc1', type=float, default=None, help='Person1上升黄经')
|
||||
p.add_argument('--asc2', type=float, default=None, help='Person2上升黄经')
|
||||
p.add_argument('--gender1', default='M', help='Person1性别')
|
||||
p.add_argument('--gender2', default='F', help='Person2性别')
|
||||
# 21. marriage-match(上游 bde5d615;只收盘快照)
|
||||
p = sub.add_parser('marriage-match', help='婚恋合盘(两份盘快照)')
|
||||
p.add_argument('--person-a', required=True, help='Person A 盘快照 JSON 文件')
|
||||
p.add_argument('--person-b', required=True, help='Person B 盘快照 JSON 文件')
|
||||
p.add_argument('--profile', default='practitioner_consensus_v1',
|
||||
choices=['practitioner_consensus_v1', 'raman_muhurtha_v1'])
|
||||
|
||||
# 22. full-reading (v3.7.1新增)
|
||||
p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)')
|
||||
@@ -18371,7 +18364,7 @@ def main():
|
||||
'varga-full': cmd_varga_full, 'aspects': cmd_aspects, 'jaimini': cmd_jaimini,
|
||||
'nakshatra-adv': cmd_nakshatra_adv, 'nakshatra-dasha': cmd_nakshatra_dasha,
|
||||
'nakshatra-full': cmd_nakshatra_full, 'argala': cmd_argala, 'tajika': cmd_tajika,
|
||||
'synastry': cmd_synastry, 'solar-return': cmd_solar_return,
|
||||
'marriage-match': cmd_marriage_match, 'solar-return': cmd_solar_return,
|
||||
'narayana-dasha': cmd_narayana_dasha, 'muhurta': cmd_muhurta,
|
||||
'full-reading': cmd_full_reading, 'pl9-export': cmd_pl9_export, 'prashna': cmd_prashna,
|
||||
'double-transit-pac': cmd_double_transit_pac,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
"""User-facing romantic/marriage compatibility contract.
|
||||
|
||||
This module deliberately does not reuse the historical ``calc_kuja_dosha``
|
||||
score. That function combines Mars with five pressure planets and contains a
|
||||
male/female asymmetric comparison. It remains available for legacy reports,
|
||||
but is not a valid default for a new relationship product.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
from ashtakoot import (
|
||||
FRIENDSHIP,
|
||||
NADI,
|
||||
SIGN_LORDS,
|
||||
SIGNS,
|
||||
calculate_ashtakoot,
|
||||
get_nakshatra,
|
||||
)
|
||||
|
||||
|
||||
PRODUCT_VERSION = "marriage_match_product_v1"
|
||||
DEFAULT_PROFILE = "practitioner_consensus_v1"
|
||||
VALID_RELATIONSHIP_TYPES = {"romantic_or_marriage", "romantic", "marriage"}
|
||||
KUJA_PLANET = "Mars"
|
||||
PAPASAMYA_PLANETS = ("Mars", "Saturn", "Sun", "Rahu", "Ketu")
|
||||
|
||||
# These profiles are deliberately qualitative. The consensus did not support
|
||||
# an invented severity percentage or a sex-specific tolerance threshold.
|
||||
RULE_PROFILES = {
|
||||
"practitioner_consensus_v1": {
|
||||
"label": "Practitioner consensus v1",
|
||||
"kuja_houses": (1, 2, 4, 7, 8, 12),
|
||||
"reference_frames": ("lagna", "moon", "venus"),
|
||||
"exact_house_sign_exceptions": {
|
||||
2: {"Gemini", "Virgo"},
|
||||
4: {"Aries", "Scorpio"},
|
||||
7: {"Capricorn", "Cancer"},
|
||||
8: {"Sagittarius", "Pisces"},
|
||||
12: {"Taurus", "Libra"},
|
||||
},
|
||||
"leo_aquarius_exception": "variant_only",
|
||||
"own_or_exalted": "mitigates",
|
||||
"jupiter_contact": "mitigates",
|
||||
"moon_contact": "mitigates",
|
||||
"papasamya_planets": PAPASAMYA_PLANETS,
|
||||
"gender_asymmetry": False,
|
||||
"notes": [
|
||||
"Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.",
|
||||
"Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.",
|
||||
"Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.",
|
||||
"Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion.",
|
||||
],
|
||||
},
|
||||
"raman_muhurtha_v1": {
|
||||
"label": "B. V. Raman Muhurtha variant",
|
||||
"kuja_houses": (2, 4, 7, 8, 12),
|
||||
"reference_frames": ("lagna", "moon", "venus"),
|
||||
"exact_house_sign_exceptions": {
|
||||
2: {"Gemini", "Virgo"},
|
||||
4: {"Aries", "Scorpio"},
|
||||
7: {"Capricorn", "Cancer"},
|
||||
8: {"Sagittarius", "Pisces"},
|
||||
12: {"Taurus", "Libra"},
|
||||
},
|
||||
"leo_aquarius_exception": "cancels",
|
||||
"own_or_exalted": "mitigates",
|
||||
"jupiter_contact": "mitigates",
|
||||
"moon_contact": "mitigates",
|
||||
"papasamya_planets": PAPASAMYA_PLANETS,
|
||||
"gender_asymmetry": False,
|
||||
"notes": [
|
||||
"Raman-style house set omits the first house. It is a selectable lineage, not the product default.",
|
||||
"The traditional Leo/Aquarius exception is applied only in this variant profile.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def available_profiles() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a presentation-safe copy of available rule profiles."""
|
||||
return deepcopy(RULE_PROFILES)
|
||||
|
||||
|
||||
def _profile(profile_id: str) -> Dict[str, Any]:
|
||||
if profile_id not in RULE_PROFILES:
|
||||
raise ValueError("Unknown marriage-match rule profile: %s" % profile_id)
|
||||
return RULE_PROFILES[profile_id]
|
||||
|
||||
|
||||
def _lon(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value) % 360.0
|
||||
if isinstance(value, Mapping):
|
||||
for key in ("longitude", "degree_raw", "degree", "lon"):
|
||||
if isinstance(value.get(key), (int, float)):
|
||||
return float(value[key]) % 360.0
|
||||
return None
|
||||
|
||||
|
||||
def _sign_index(value: Any) -> Optional[int]:
|
||||
if isinstance(value, str) and value in SIGNS:
|
||||
return SIGNS.index(value)
|
||||
longitude = _lon(value)
|
||||
return int(longitude // 30) if longitude is not None else None
|
||||
|
||||
|
||||
def _planet_data(chart: Mapping[str, Any], planet: str) -> Mapping[str, Any]:
|
||||
planets = chart.get("planets", {}) if isinstance(chart, Mapping) else {}
|
||||
value = planets.get(planet, {}) if isinstance(planets, Mapping) else {}
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _planet_lon(chart: Mapping[str, Any], planet: str) -> Optional[float]:
|
||||
value = _planet_data(chart, planet)
|
||||
longitude = _lon(value)
|
||||
return longitude if longitude is not None else _lon(chart.get(planet + "_lon"))
|
||||
|
||||
|
||||
def _reference_lon(chart: Mapping[str, Any], reference: str) -> Optional[float]:
|
||||
if reference == "lagna":
|
||||
for value in (chart.get("ascendant"), chart.get("lagna"), chart.get("asc_lon")):
|
||||
longitude = _lon(value)
|
||||
if longitude is not None:
|
||||
return longitude
|
||||
return None
|
||||
return _planet_lon(chart, "Moon" if reference == "moon" else "Venus")
|
||||
|
||||
|
||||
def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]:
|
||||
value = _reference_lon(chart, reference)
|
||||
if value is not None:
|
||||
return int(value // 30)
|
||||
if reference == "lagna":
|
||||
# Aries is index 0, which is a valid reference sign rather than a
|
||||
# missing value. Do not use boolean fallback here.
|
||||
ascendant_sign = _sign_index(chart.get("ascendant"))
|
||||
return ascendant_sign if ascendant_sign is not None else _sign_index(chart.get("lagna"))
|
||||
return _sign_index(_planet_data(chart, "Moon" if reference == "moon" else "Venus"))
|
||||
|
||||
|
||||
def _planet_sign(chart: Mapping[str, Any], planet: str) -> Optional[int]:
|
||||
lon = _planet_lon(chart, planet)
|
||||
return int(lon // 30) if lon is not None else _sign_index(_planet_data(chart, planet))
|
||||
|
||||
|
||||
def _house_from(reference_sign: Optional[int], planet_sign: Optional[int]) -> Optional[int]:
|
||||
if reference_sign is None or planet_sign is None:
|
||||
return None
|
||||
return ((planet_sign - reference_sign) % 12) + 1
|
||||
|
||||
|
||||
def _same_sign_contact(chart: Mapping[str, Any], planet_a: str, planet_b: str) -> bool:
|
||||
a, b = _planet_sign(chart, planet_a), _planet_sign(chart, planet_b)
|
||||
return a is not None and a == b
|
||||
|
||||
|
||||
def _mars_dignity(chart: Mapping[str, Any]) -> str:
|
||||
sign_idx = _planet_sign(chart, "Mars")
|
||||
if sign_idx is None:
|
||||
return "unknown"
|
||||
sign = SIGNS[sign_idx]
|
||||
if sign == "Capricorn":
|
||||
return "exalted"
|
||||
if sign in {"Aries", "Scorpio"}:
|
||||
return "own"
|
||||
if sign == "Cancer":
|
||||
return "debilitated"
|
||||
return "other"
|
||||
|
||||
|
||||
def _frame_kuja(chart: Mapping[str, Any], reference: str, profile: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
mars_sign_idx = _planet_sign(chart, "Mars")
|
||||
ref_sign_idx = _reference_sign(chart, reference)
|
||||
house = _house_from(ref_sign_idx, mars_sign_idx)
|
||||
frame = {
|
||||
"reference": reference,
|
||||
"reference_sign": SIGNS[ref_sign_idx] if ref_sign_idx is not None else None,
|
||||
"mars_sign": SIGNS[mars_sign_idx] if mars_sign_idx is not None else None,
|
||||
"mars_house": house,
|
||||
"raw_signal": house in set(profile["kuja_houses"]) if house is not None else None,
|
||||
"status": "not_available" if house is None else "not_triggered",
|
||||
"mitigations": [],
|
||||
}
|
||||
if house is None or not frame["raw_signal"]:
|
||||
return frame
|
||||
|
||||
mars_sign = frame["mars_sign"]
|
||||
exact_exceptions = profile["exact_house_sign_exceptions"]
|
||||
if mars_sign in exact_exceptions.get(house, set()):
|
||||
frame["status"] = "cancelled_by_profile_exception"
|
||||
frame["mitigations"].append("house_sign_exception")
|
||||
return frame
|
||||
if mars_sign in {"Leo", "Aquarius"}:
|
||||
mode = profile["leo_aquarius_exception"]
|
||||
if mode == "cancels":
|
||||
frame["status"] = "cancelled_by_profile_exception"
|
||||
frame["mitigations"].append("leo_aquarius_profile_exception")
|
||||
return frame
|
||||
if mode == "variant_only":
|
||||
frame["mitigations"].append("leo_aquarius_exception_disputed")
|
||||
|
||||
frame["status"] = "active"
|
||||
dignity = _mars_dignity(chart)
|
||||
if dignity in {"own", "exalted"}:
|
||||
frame["mitigations"].append("mars_%s" % dignity)
|
||||
if _same_sign_contact(chart, "Mars", "Jupiter"):
|
||||
frame["mitigations"].append("jupiter_conjunction")
|
||||
if _same_sign_contact(chart, "Mars", "Moon"):
|
||||
frame["mitigations"].append("moon_conjunction")
|
||||
return frame
|
||||
|
||||
|
||||
def analyze_kuja(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Analyze Mars-only Kuja / Mangal Dosha across visible reference frames."""
|
||||
profile = _profile(profile_id)
|
||||
if _planet_sign(chart, "Mars") is None:
|
||||
return {
|
||||
"system": "Kuja / Mangal Dosha",
|
||||
"scope": "Mars only",
|
||||
"profile_id": profile_id,
|
||||
"status": "not_available",
|
||||
"reason": "Mars position is required.",
|
||||
"frames": [],
|
||||
}
|
||||
frames = [_frame_kuja(chart, ref, profile) for ref in profile["reference_frames"]]
|
||||
active = [frame for frame in frames if frame["status"] == "active"]
|
||||
cancelled = [frame for frame in frames if frame["status"] == "cancelled_by_profile_exception"]
|
||||
severity = {0: "none", 1: "mild", 2: "material", 3: "strong"}[len(active)]
|
||||
return {
|
||||
"system": "Kuja / Mangal Dosha",
|
||||
"scope": "Mars only",
|
||||
"profile_id": profile_id,
|
||||
"status": "executed",
|
||||
"active_reference_count": len(active),
|
||||
"cancelled_reference_count": len(cancelled),
|
||||
"qualitative_level": severity,
|
||||
"frames": frames,
|
||||
"boundary": "Reference count is a presentation label, not a probability or a compatibility score.",
|
||||
}
|
||||
|
||||
|
||||
def analyze_papasamya(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Report five-planet pressure separately from Mars-only Kuja."""
|
||||
profile = _profile(profile_id)
|
||||
rows = []
|
||||
for planet in profile["papasamya_planets"]:
|
||||
planet_sign = _planet_sign(chart, planet)
|
||||
if planet_sign is None:
|
||||
rows.append({"planet": planet, "status": "not_available", "frames": []})
|
||||
continue
|
||||
frames = []
|
||||
for reference in profile["reference_frames"]:
|
||||
house = _house_from(_reference_sign(chart, reference), planet_sign)
|
||||
frames.append({
|
||||
"reference": reference,
|
||||
"house": house,
|
||||
"in_pressure_house": house in set(profile["kuja_houses"]) if house is not None else None,
|
||||
})
|
||||
rows.append({"planet": planet, "status": "executed", "frames": frames})
|
||||
active = [
|
||||
{"planet": row["planet"], "reference": frame["reference"], "house": frame["house"]}
|
||||
for row in rows for frame in row.get("frames", []) if frame.get("in_pressure_house")
|
||||
]
|
||||
return {
|
||||
"system": "Papasamya pressure",
|
||||
"scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.",
|
||||
"profile_id": profile_id,
|
||||
"status": "executed" if any(row["status"] == "executed" for row in rows) else "not_available",
|
||||
"planet_rows": rows,
|
||||
"active_placements": active,
|
||||
"boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score.",
|
||||
}
|
||||
|
||||
|
||||
def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Compare two Kuja results without sex-specific tolerance or scoring."""
|
||||
a, b = analyze_kuja(person_a, profile_id), analyze_kuja(person_b, profile_id)
|
||||
if a["status"] != "executed" or b["status"] != "executed":
|
||||
outcome = "incomplete"
|
||||
else:
|
||||
difference = abs(a["active_reference_count"] - b["active_reference_count"])
|
||||
outcome = "not_triggered" if not a["active_reference_count"] and not b["active_reference_count"] else (
|
||||
"broadly_balanced" if difference <= 1 else "asymmetric"
|
||||
)
|
||||
return {
|
||||
"comparison_method": "neutral_reference_count",
|
||||
"gender_asymmetry_used": False,
|
||||
"outcome": outcome,
|
||||
"person_a": a,
|
||||
"person_b": b,
|
||||
"boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation.",
|
||||
}
|
||||
|
||||
|
||||
def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]:
|
||||
longitude = _planet_lon(chart, "Moon")
|
||||
return longitude if longitude is not None else _lon(chart.get("moon_lon"))
|
||||
|
||||
|
||||
def _are_natural_friends(lord_a: str, lord_b: str) -> bool:
|
||||
if lord_a == lord_b:
|
||||
return True
|
||||
return lord_b in FRIENDSHIP.get(lord_a, {}).get("friend", []) and lord_a in FRIENDSHIP.get(lord_b, {}).get("friend", [])
|
||||
|
||||
|
||||
def _ashtakoota_with_exceptions(
|
||||
person_a: Mapping[str, Any],
|
||||
person_b: Mapping[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
moon_a, moon_b = _moon_lon(person_a), _moon_lon(person_b)
|
||||
if moon_a is None or moon_b is None:
|
||||
return {"status": "not_available", "reason": "Both Moon longitudes are required for Ashtakoota."}
|
||||
forward = calculate_ashtakoot(moon_a, moon_b)
|
||||
reverse = calculate_ashtakoot(moon_b, moon_a)
|
||||
# The historical Varna point encodes caste hierarchy. It cannot truthfully
|
||||
# be relabelled as a modern value judgement, so the international product
|
||||
# excludes it rather than deriving a replacement from lunar signs.
|
||||
score_names = ("Vashya", "Tara", "Yoni", "GrahaMaitri", "Gana", "Bhakoot", "Nadi")
|
||||
symmetric_scores = {
|
||||
name: round(
|
||||
(float(forward["scores"][name]) + float(reverse["scores"][name])) / 2.0,
|
||||
2,
|
||||
)
|
||||
for name in score_names
|
||||
}
|
||||
international_core = {
|
||||
"method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)",
|
||||
"scores": symmetric_scores,
|
||||
"total_score": round(sum(symmetric_scores.values()), 2),
|
||||
"max_score": 35.0,
|
||||
"match_percentage": round(sum(symmetric_scores.values()) / 35.0 * 100, 1),
|
||||
"person_a_details": {
|
||||
"moon_sign": SIGNS[int(moon_a // 30)],
|
||||
"nakshatra": get_nakshatra(moon_a)["name"],
|
||||
"nakshatra_pada": get_nakshatra(moon_a)["pada"],
|
||||
},
|
||||
"person_b_details": {
|
||||
"moon_sign": SIGNS[int(moon_b // 30)],
|
||||
"nakshatra": get_nakshatra(moon_b)["name"],
|
||||
"nakshatra_pada": get_nakshatra(moon_b)["pada"],
|
||||
},
|
||||
"directionality": {
|
||||
"status": "symmetric",
|
||||
"rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a",
|
||||
},
|
||||
"boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict.",
|
||||
}
|
||||
sign_a, sign_b = SIGNS[int(moon_a // 30)], SIGNS[int(moon_b // 30)]
|
||||
nak_a, nak_b = get_nakshatra(moon_a), get_nakshatra(moon_b)
|
||||
bhakoot_exceptions = []
|
||||
if symmetric_scores["Bhakoot"] == 0:
|
||||
lord_a, lord_b = SIGN_LORDS[sign_a], SIGN_LORDS[sign_b]
|
||||
if lord_a == lord_b:
|
||||
bhakoot_exceptions.append("same_sign_lord")
|
||||
elif _are_natural_friends(lord_a, lord_b):
|
||||
bhakoot_exceptions.append("mutual_natural_friend_sign_lords")
|
||||
nadi_exceptions = []
|
||||
if symmetric_scores["Nadi"] == 0:
|
||||
if nak_a["index"] == nak_b["index"] and nak_a["pada"] != nak_b["pada"]:
|
||||
nadi_exceptions.append("same_nakshatra_different_pada")
|
||||
if sign_a == sign_b and nak_a["index"] != nak_b["index"]:
|
||||
nadi_exceptions.append("same_rashi_different_nakshatra")
|
||||
return {
|
||||
"status": "executed",
|
||||
"international_core": international_core,
|
||||
"shared_life_dialogue": {
|
||||
"status": "not_scored",
|
||||
"storage": "no_answers_collected",
|
||||
"prompts": [
|
||||
"What does a committed relationship mean to each of you?",
|
||||
"How do you want to make decisions about money, home, and care responsibilities?",
|
||||
"What boundaries, privacy expectations, and conflict-repair habits matter most?",
|
||||
"How do you each imagine work, family, location, and personal autonomy over the next few years?",
|
||||
],
|
||||
"boundary": "These are voluntary discussion prompts, not a compatibility test or a score.",
|
||||
},
|
||||
"classical_varna": {
|
||||
"status": "excluded_from_default",
|
||||
"reason": "Historical caste hierarchy is not used in the international product or its default score.",
|
||||
},
|
||||
"person_a": {"moon_sign": sign_a, "nakshatra": nak_a},
|
||||
"person_b": {"moon_sign": sign_b, "nakshatra": nak_b},
|
||||
"exception_review": {
|
||||
"bhakoot": {
|
||||
"raw_score": symmetric_scores["Bhakoot"],
|
||||
"candidate_cancellations": bhakoot_exceptions,
|
||||
"status": "candidate_mitigation" if bhakoot_exceptions else "none",
|
||||
},
|
||||
"nadi": {
|
||||
"raw_score": symmetric_scores["Nadi"],
|
||||
"candidate_cancellations": nadi_exceptions,
|
||||
"status": "candidate_mitigation" if nadi_exceptions else "none",
|
||||
},
|
||||
"yoni": {
|
||||
"raw_score": symmetric_scores["Yoni"],
|
||||
"status": "legacy_three_band_table",
|
||||
"boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _structure_status(chart: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
keys = {"d1_relationship", "d9", "upapada", "darakaraka", "relationship_dasha"}
|
||||
supplied = [key for key in keys if chart.get(key) is not None]
|
||||
return {
|
||||
"status": "partial" if supplied else "not_available",
|
||||
"supplied_layers": sorted(supplied),
|
||||
"required_for_complete_reading": ["D1 relationship structure", "D9", "UL", "DK", "relationship timing / Dasha"],
|
||||
"boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review.",
|
||||
}
|
||||
|
||||
|
||||
def build_marriage_match_dossier(
|
||||
person_a: Mapping[str, Any],
|
||||
person_b: Mapping[str, Any],
|
||||
*,
|
||||
relationship_type: str = "romantic_or_marriage",
|
||||
profile_id: str = DEFAULT_PROFILE,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the stable, user-facing love/marriage compatibility payload.
|
||||
|
||||
Inputs are chart snapshots only. The caller is responsible for calculating
|
||||
D1/D9/timing snapshots; this function never stores birth details.
|
||||
"""
|
||||
if relationship_type not in VALID_RELATIONSHIP_TYPES:
|
||||
raise ValueError("This product supports romantic/marriage matching only.")
|
||||
profile = _profile(profile_id)
|
||||
kuja = compare_kuja(person_a, person_b, profile_id)
|
||||
ashtakoota = _ashtakoota_with_exceptions(person_a, person_b)
|
||||
return {
|
||||
"product": PRODUCT_VERSION,
|
||||
"relationship_type": relationship_type,
|
||||
"rule_profile": {
|
||||
"id": profile_id,
|
||||
"label": profile["label"],
|
||||
"notes": profile["notes"],
|
||||
"gender_asymmetry_used": False,
|
||||
},
|
||||
"ashtakoota_36": ashtakoota,
|
||||
"kuja_mangal_dosha": kuja,
|
||||
"papasamya": {
|
||||
"person_a": analyze_papasamya(person_a, profile_id),
|
||||
"person_b": analyze_papasamya(person_b, profile_id),
|
||||
},
|
||||
"relationship_structure": {
|
||||
"person_a": _structure_status(person_a),
|
||||
"person_b": _structure_status(person_b),
|
||||
},
|
||||
"presentation": {
|
||||
"headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.",
|
||||
"decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.",
|
||||
"not_in_scope": ["business partnerships", "family matching", "age-based cancellation", "sex-asymmetric tolerance"],
|
||||
},
|
||||
"audit": {
|
||||
"kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied",
|
||||
"papasamya": "executed separately: five-planet pressure placement review",
|
||||
"ashtakoota": ashtakoota["status"],
|
||||
"d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""HTTP-facing marriage match and full synastry dossier.
|
||||
|
||||
The dossier endpoint caches on the two birth payloads plus ``GITHUB_SHA``,
|
||||
the same scratch helper the report reference cache uses. A missing or
|
||||
``unknown`` commit id skips the cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
from synastry_full_dossier import RULES_VERSION, build_synastry_full_dossier
|
||||
|
||||
try:
|
||||
from scripts import consult_full_data_cache as _cache
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
import consult_full_data_cache as _cache
|
||||
|
||||
|
||||
CACHE_NAME = "synastry_full_dossier_cache"
|
||||
_BIRTH_KEYS = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz")
|
||||
|
||||
|
||||
class MarriageMatchInputError(ValueError):
|
||||
"""The caller sent a shape this product does not accept."""
|
||||
|
||||
|
||||
def compute_marriage_match(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Snapshot-only product. Birth details are not accepted here."""
|
||||
if not isinstance(payload, dict):
|
||||
raise MarriageMatchInputError("request body must be an object")
|
||||
person_a = payload.get("person_a")
|
||||
person_b = payload.get("person_b")
|
||||
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
|
||||
raise MarriageMatchInputError("person_a and person_b must be chart snapshots")
|
||||
profile_id = payload.get("profile_id") or "practitioner_consensus_v1"
|
||||
if not isinstance(profile_id, str) or not profile_id.strip():
|
||||
raise MarriageMatchInputError("profile_id must be a string")
|
||||
try:
|
||||
return build_marriage_match_dossier(
|
||||
person_a,
|
||||
person_b,
|
||||
profile_id=profile_id.strip(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise MarriageMatchInputError(str(exc)) from exc
|
||||
|
||||
|
||||
def _birth(value: Any, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise MarriageMatchInputError(f"{label} must be an object")
|
||||
missing = [key for key in _BIRTH_KEYS if value.get(key) is None]
|
||||
if missing:
|
||||
raise MarriageMatchInputError(f"{label} is missing {', '.join(missing)}")
|
||||
return value
|
||||
|
||||
|
||||
def _birth_identity(body: dict[str, Any]) -> dict[str, Any]:
|
||||
node = body.get("node_mode", body.get("nodeMode", "mean"))
|
||||
return {
|
||||
"year": body.get("year"),
|
||||
"month": body.get("month"),
|
||||
"day": body.get("day"),
|
||||
"hour": body.get("hour"),
|
||||
"minute": body.get("minute"),
|
||||
"second": body.get("second", 0),
|
||||
"lat": body.get("lat"),
|
||||
"lon": body.get("lon"),
|
||||
"tz": body.get("tz"),
|
||||
"ayanamsa": body.get("ayanamsa", "lahiri"),
|
||||
"node_mode": node or "mean",
|
||||
}
|
||||
|
||||
|
||||
def _commit_sha() -> str:
|
||||
return os.environ.get("GITHUB_SHA") or ""
|
||||
|
||||
|
||||
def compute_synastry_full_dossier(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise MarriageMatchInputError("request body must be an object")
|
||||
person_a = _birth(body.get("person_a"), "person_a")
|
||||
person_b = _birth(body.get("person_b"), "person_b")
|
||||
sha = _commit_sha()
|
||||
|
||||
def build(_identity: dict[str, Any]) -> dict[str, Any]:
|
||||
return build_synastry_full_dossier(handler, person_a, person_b)
|
||||
|
||||
if sha in ("", "unknown"):
|
||||
return build({})
|
||||
cached = _cache.get_or_build(
|
||||
{
|
||||
"person_a": _birth_identity(person_a),
|
||||
"person_b": _birth_identity(person_b),
|
||||
"github_sha": sha,
|
||||
"rules_version": RULES_VERSION,
|
||||
},
|
||||
build,
|
||||
cache_name=CACHE_NAME,
|
||||
)
|
||||
packet = cached["packet"]
|
||||
if not isinstance(packet, dict):
|
||||
raise MarriageMatchInputError("cached dossier was not an object")
|
||||
return packet
|
||||
@@ -0,0 +1,20 @@
|
||||
"""CLI for snapshot marriage matching. Birth details are not an input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
|
||||
|
||||
def run_marriage_match_cli(args) -> dict:
|
||||
person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8"))
|
||||
person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8"))
|
||||
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
|
||||
raise ValueError("person snapshots must be JSON objects")
|
||||
return build_marriage_match_dossier(
|
||||
person_a,
|
||||
person_b,
|
||||
profile_id=getattr(args, "profile", None) or "practitioner_consensus_v1",
|
||||
)
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Synastry(合盘)模块 — 基于 dashaflow (MIT License) 核心算法
|
||||
|
||||
16因子兼容性分析系统:
|
||||
- Ashtakoot 8因子(36分制)
|
||||
- 附加8 Kuta(Mahendra/Stree Deergha/Vedha/Kuja Dosha/Rajju等)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Any
|
||||
|
||||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||||
|
||||
SIGN_LORDS = {
|
||||
'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon',
|
||||
'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars',
|
||||
'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter'
|
||||
}
|
||||
|
||||
NAKSHATRAS = ['Ashwini', 'Bharani', 'Krittika', 'Rohini', 'Mrigashira', 'Ardra',
|
||||
'Punarvasu', 'Pushya', 'Ashlesha', 'Magha', 'Purva Phalguni', 'Uttara Phalguni',
|
||||
'Hasta', 'Chitra', 'Swati', 'Vishakha', 'Anuradha', 'Jyeshtha',
|
||||
'Mula', 'Purva Ashadha', 'Uttara Ashadha', 'Shravana', 'Dhanishta', 'Shatabhisha',
|
||||
'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati']
|
||||
|
||||
# =============================================================================
|
||||
# 数据表 (来自 dashaflow MIT License)
|
||||
# =============================================================================
|
||||
|
||||
YONI_ANIMALS = [
|
||||
"Horse", "Elephant", "Sheep", "Serpent", "Serpent", "Dog", "Cat", "Sheep", "Cat",
|
||||
"Rat", "Rat", "Cow", "Buffalo", "Tiger", "Buffalo", "Tiger", "Deer", "Deer",
|
||||
"Dog", "Monkey", "Mongoose", "Monkey", "Lion", "Horse", "Lion", "Cow", "Elephant"
|
||||
]
|
||||
|
||||
YONI_ENEMIES = {
|
||||
"Horse": "Buffalo", "Buffalo": "Horse", "Elephant": "Lion", "Lion": "Elephant",
|
||||
"Sheep": "Monkey", "Monkey": "Sheep", "Serpent": "Mongoose", "Mongoose": "Serpent",
|
||||
"Dog": "Deer", "Deer": "Dog", "Cat": "Rat", "Rat": "Cat",
|
||||
"Cow": "Tiger", "Tiger": "Cow"
|
||||
}
|
||||
|
||||
GANA = [
|
||||
"Deva", "Manushya", "Rakshasa", "Manushya", "Deva", "Manushya", "Deva", "Deva", "Rakshasa",
|
||||
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Deva", "Rakshasa", "Deva", "Rakshasa",
|
||||
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Rakshasa", "Manushya", "Manushya", "Deva"
|
||||
]
|
||||
|
||||
NADI = [
|
||||
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya",
|
||||
"Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi",
|
||||
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya"
|
||||
]
|
||||
|
||||
VARNA = {
|
||||
"Cancer": 1, "Scorpio": 1, "Pisces": 1,
|
||||
"Aries": 2, "Leo": 2, "Sagittarius": 2,
|
||||
"Taurus": 3, "Virgo": 3, "Capricorn": 3,
|
||||
"Gemini": 4, "Libra": 4, "Aquarius": 4
|
||||
}
|
||||
|
||||
VASHYA_TYPE = {
|
||||
"Aries": "Chatushpada", "Taurus": "Chatushpada",
|
||||
"Leo": "Vanachara", "Sagittarius": "Chatushpada", "Capricorn": "Chatushpada",
|
||||
"Gemini": "Manava", "Virgo": "Manava", "Libra": "Manava", "Aquarius": "Manava",
|
||||
"Cancer": "Jalachara", "Pisces": "Jalachara",
|
||||
"Scorpio": "Keet",
|
||||
}
|
||||
|
||||
VEDHA_PAIRS = [(0,17),(1,16),(2,15),(3,14),(5,21),(6,20),(7,19),(8,18),(9,26),(10,25),(11,24),(12,23),(4,22)]
|
||||
|
||||
RAJJU_GROUPS = {
|
||||
"Pada":{0,8,9,17,18,26}, "Kati":{1,7,10,16,25,19},
|
||||
"Udara":{2,6,11,15,20,24}, "Kanta":{3,5,12,14,21,23}, "Sira":{4,13,22}
|
||||
}
|
||||
RAJJU_EFFECTS = {"Sira":"head—risk to husband","Kanta":"neck—risk to wife","Udara":"stomach—risk to children","Kati":"waist—poverty","Pada":"foot—wandering"}
|
||||
|
||||
NATURAL_FRIENDS = {
|
||||
'Sun': ['Moon','Mars','Jupiter'], 'Moon': ['Sun','Mercury'],
|
||||
'Mars': ['Sun','Moon','Jupiter'], 'Mercury': ['Sun','Venus'],
|
||||
'Jupiter': ['Sun','Moon','Mars'], 'Venus': ['Mercury','Saturn'],
|
||||
'Saturn': ['Mercury','Venus']
|
||||
}
|
||||
NATURAL_ENEMIES = {
|
||||
'Sun': ['Saturn','Venus'], 'Moon': [], 'Mars': ['Mercury'],
|
||||
'Mercury': ['Moon'], 'Jupiter': ['Mercury','Venus'],
|
||||
'Venus': ['Sun','Moon'], 'Saturn': ['Sun','Moon','Mars']
|
||||
}
|
||||
|
||||
EXALTATION = {'Sun':('Aries',10),'Moon':('Taurus',3),'Mars':('Capricorn',28),'Mercury':('Virgo',15),'Jupiter':('Cancer',5),'Venus':('Pisces',27),'Saturn':('Libra',20)}
|
||||
DEBILITATION = {p:(SIGNS[(SIGNS.index(s)+6)%12],d) for p,(s,d) in EXALTATION.items()}
|
||||
OWN_SIGNS = {'Sun':{'Leo'},'Moon':{'Cancer'},'Mars':{'Aries','Scorpio'},'Mercury':{'Gemini','Virgo'},'Jupiter':{'Sagittarius','Pisces'},'Venus':{'Taurus','Libra'},'Saturn':{'Capricorn','Aquarius'}}
|
||||
|
||||
_DOSHA_HOUSES = {2,4,7,8,12}
|
||||
_HIGH_SEVERITY_HOUSES = {7,8}
|
||||
_MARS_EXCEPTIONS = {2:{"Gemini","Virgo"},12:{"Taurus","Libra"},4:{"Aries","Scorpio"},7:{"Capricorn","Cancer"},8:{"Sagittarius","Pisces"}}
|
||||
_MARS_EXEMPT_SIGNS = {"Aquarius","Leo"}
|
||||
|
||||
# =============================================================================
|
||||
# Ashtakoot 8因子 (36分制)
|
||||
# =============================================================================
|
||||
|
||||
def _calc_varna(m_sign, f_sign):
|
||||
return 1.0 if VARNA[m_sign] <= VARNA[f_sign] else 0.0
|
||||
|
||||
def _calc_tara(m_nak, f_nak):
|
||||
pts = 0.0
|
||||
if (f_nak-m_nak)%9 not in (2,4,6): pts += 1.5
|
||||
if (m_nak-f_nak)%9 not in (2,4,6): pts += 1.5
|
||||
return pts
|
||||
|
||||
def _calc_yoni(m_nak, f_nak):
|
||||
my, fy = YONI_ANIMALS[m_nak], YONI_ANIMALS[f_nak]
|
||||
if my == fy: return 4.0
|
||||
if YONI_ENEMIES.get(my) == fy: return 0.0
|
||||
return 2.0
|
||||
|
||||
def _calc_graha_maitri(m_sign, f_sign):
|
||||
ml, fl = SIGN_LORDS[m_sign], SIGN_LORDS[f_sign]
|
||||
def _check(p1, p2):
|
||||
if p1==p2: return 1.0
|
||||
if p2 in NATURAL_FRIENDS.get(p1,[]): return 1.0
|
||||
if p2 in NATURAL_ENEMIES.get(p1,[]): return 0.0
|
||||
return 0.5
|
||||
total = _check(ml,fl) + _check(fl,ml)
|
||||
if total == 2.0: return 5.0
|
||||
if total == 1.5: return 4.0
|
||||
if total == 1.0: return 3.0
|
||||
if total == 0.5: return 1.0
|
||||
return 0.0
|
||||
|
||||
def _calc_gana(m_nak, f_nak):
|
||||
mg, fg = GANA[m_nak], GANA[f_nak]
|
||||
if mg == fg: return 6.0
|
||||
if mg=="Deva" and fg=="Manushya": return 6.0
|
||||
if mg=="Manushya" and fg=="Deva": return 5.0
|
||||
if "Rakshasa" in (mg,fg) and "Manushya" in (mg,fg): return 0.0
|
||||
if mg=="Rakshasa" and fg=="Deva": return 1.0
|
||||
if fg=="Rakshasa" and mg=="Deva": return 0.0
|
||||
return 0.0
|
||||
|
||||
def _calc_bhakoot(m_sign, f_sign):
|
||||
mi, fi = SIGNS.index(m_sign), SIGNS.index(f_sign)
|
||||
diff = (fi - mi) % 12 + 1
|
||||
return 7.0 if diff in (1,7,3,11,4,10) else 0.0
|
||||
|
||||
def _calc_nadi(m_nak, f_nak):
|
||||
return 8.0 if NADI[m_nak] != NADI[f_nak] else 0.0
|
||||
|
||||
def _calc_vedha(m_nak, f_nak):
|
||||
for a,b in VEDHA_PAIRS:
|
||||
if (m_nak==a and f_nak==b) or (m_nak==b and f_nak==a): return "bad"
|
||||
return "good"
|
||||
|
||||
def _calc_rajju(m_nak, f_nak):
|
||||
for group, indices in RAJJU_GROUPS.items():
|
||||
if m_nak in indices and f_nak in indices:
|
||||
return {"result":"bad","group":group,"effect":RAJJU_EFFECTS[group]}
|
||||
return {"result":"good","group":None,"effect":""}
|
||||
|
||||
def _calc_mahendra(m_nak, f_nak):
|
||||
cnt = ((m_nak-f_nak)%27)+1
|
||||
return "good" if cnt in (4,7,10,13,16,19,22,25) else "bad"
|
||||
|
||||
def _calc_stree_deergha(m_nak, f_nak):
|
||||
cnt = ((m_nak-f_nak)%27)+1
|
||||
return "good" if cnt >= 9 else "bad"
|
||||
|
||||
# =============================================================================
|
||||
# 主计算函数
|
||||
# =============================================================================
|
||||
|
||||
def calc_ashtakoot(male_moon_degree: float, female_moon_degree: float,
|
||||
male_chart: Dict = None, female_chart: Dict = None) -> Dict:
|
||||
"""
|
||||
计算16因子Ashtakoot兼容性匹配(基于dashaflow MIT算法)。
|
||||
|
||||
Args:
|
||||
male_moon_degree: 男性月亮黄道经度(0-360)
|
||||
female_moon_degree: 女性月亮黄道经度(0-360)
|
||||
male_chart: 男性星盘数据(可选,用于附加Kuta)
|
||||
female_chart: 女性星盘数据(可选)
|
||||
|
||||
Returns:
|
||||
完整的兼容性分析
|
||||
"""
|
||||
m_nak = int(male_moon_degree / (360.0/27.0))
|
||||
f_nak = int(female_moon_degree / (360.0/27.0))
|
||||
m_sign = SIGNS[int(male_moon_degree/30)%12]
|
||||
f_sign = SIGNS[int(female_moon_degree/30)%12]
|
||||
|
||||
scores = {
|
||||
"Varna": _calc_varna(m_sign, f_sign),
|
||||
"Vashya": 2.0, # Simplified
|
||||
"Tara": _calc_tara(m_nak, f_nak),
|
||||
"Yoni": _calc_yoni(m_nak, f_nak),
|
||||
"GrahaMaitri": _calc_graha_maitri(m_sign, f_sign),
|
||||
"Gana": _calc_gana(m_nak, f_nak),
|
||||
"Bhakoot": _calc_bhakoot(m_sign, f_sign),
|
||||
"Nadi": _calc_nadi(m_nak, f_nak),
|
||||
}
|
||||
total = sum(scores.values())
|
||||
|
||||
rajju = _calc_rajju(m_nak, f_nak)
|
||||
additional = {
|
||||
"Mahendra": _calc_mahendra(m_nak, f_nak),
|
||||
"StreeDeergha": _calc_stree_deergha(m_nak, f_nak),
|
||||
"Vedha": _calc_vedha(m_nak, f_nak),
|
||||
"Rajju": rajju,
|
||||
}
|
||||
|
||||
# Dosha缓解逻辑
|
||||
exceptions = []
|
||||
if scores["Nadi"]==0 and scores["Bhakoot"]>0 and rajju["result"]=="good":
|
||||
exceptions.append("Nadi Dosha mitigated by good Bhakoot and Rajju")
|
||||
if rajju["result"]=="bad" and scores["GrahaMaitri"]>=4 and scores["Bhakoot"]>0 and scores["Tara"]>=1.5 and additional["Mahendra"]=="good":
|
||||
exceptions.append("Rajju Dosha mitigated")
|
||||
|
||||
assessment = "优秀" if total>=28 else "良好" if total>=21 else "一般" if total>=18 else "不推荐"
|
||||
approved = total >= 18.0 and (scores["Nadi"]>0 or len(exceptions)>0)
|
||||
|
||||
return {
|
||||
"version": "3.8-dashaflow-mit-adapted",
|
||||
"method": "Ashtakoot 16因子兼容性分析 (dashaflow MIT)",
|
||||
"male": {"moon_sign":m_sign,"nakshatra":NAKSHATRAS[m_nak],"gana":GANA[m_nak],"nadi":NADI[m_nak],"yoni":YONI_ANIMALS[m_nak]},
|
||||
"female": {"moon_sign":f_sign,"nakshatra":NAKSHATRAS[f_nak],"gana":GANA[f_nak],"nadi":NADI[f_nak],"yoni":YONI_ANIMALS[f_nak]},
|
||||
"scores": scores,
|
||||
"total_score": total,
|
||||
"max_score": 36.0,
|
||||
"assessment": assessment,
|
||||
"is_approved": approved,
|
||||
"additional_kutas": additional,
|
||||
"exceptions": exceptions,
|
||||
}
|
||||
|
||||
|
||||
def calc_synastry(male_chart: Dict, female_chart: Dict) -> Dict:
|
||||
"""
|
||||
Backward-compatible synastry wrapper used by integration tests and older CLI paths.
|
||||
|
||||
Expected chart keys:
|
||||
- moon_lon: Moon longitude in degrees (required)
|
||||
- mars_lon / asc_lon / gender: optional, reserved for Kuja and extended factors
|
||||
"""
|
||||
if "moon_lon" not in male_chart or "moon_lon" not in female_chart:
|
||||
raise ValueError("calc_synastry requires moon_lon in both male_chart and female_chart")
|
||||
|
||||
result = calc_ashtakoot(
|
||||
float(male_chart["moon_lon"]),
|
||||
float(female_chart["moon_lon"]),
|
||||
male_chart=male_chart,
|
||||
female_chart=female_chart,
|
||||
)
|
||||
|
||||
# Historical test/API compatibility: expose BadConstellations as an additional Kuta.
|
||||
# A bad constellation condition is present if Vedha or Rajju is adverse.
|
||||
vedha_bad = result["additional_kutas"].get("Vedha") == "bad"
|
||||
rajju_bad = result["additional_kutas"].get("Rajju", {}).get("result") == "bad"
|
||||
result["additional_kutas"]["BadConstellations"] = "bad" if (vedha_bad or rajju_bad) else "good"
|
||||
return result
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Glue around the upstream full-synastry skill.
|
||||
|
||||
Two birth payloads become two native full-readings, then the untouched
|
||||
``build_full_synastry_dossier``. Raman is applied to the snapshots the skill
|
||||
already extracted. This module does not edit those upstream files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOSSIER_SCRIPT = (
|
||||
ROOT
|
||||
/ "skills"
|
||||
/ "jyotish-full-synastry-reading"
|
||||
/ "scripts"
|
||||
/ "build_full_synastry_dossier.py"
|
||||
)
|
||||
RULES_VERSION = "bde5d615:marriage_match_product_v1"
|
||||
RAMAN_PROFILE = "raman_muhurtha_v1"
|
||||
|
||||
ReadingFn = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
def _dossier_builder():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"build_full_synastry_dossier",
|
||||
DOSSIER_SCRIPT,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("full synastry dossier script is missing")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.build_full_synastry_dossier
|
||||
|
||||
|
||||
def build_synastry_full_dossier(
|
||||
handler: Any,
|
||||
person_a: dict[str, Any],
|
||||
person_b: dict[str, Any],
|
||||
*,
|
||||
read_chart: ReadingFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run two full-readings in parallel, then both marriage-match profiles."""
|
||||
read = read_chart or handler._compute_full_reading_for_thematic
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
future_a = pool.submit(read, person_a)
|
||||
future_b = pool.submit(read, person_b)
|
||||
reading_a = future_a.result()
|
||||
reading_b = future_b.result()
|
||||
dossier = _dossier_builder()(reading_a, reading_b)
|
||||
snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {}
|
||||
snapshots = snapshots if isinstance(snapshots, dict) else {}
|
||||
raman = build_marriage_match_dossier(
|
||||
snapshots.get("person_a") or {},
|
||||
snapshots.get("person_b") or {},
|
||||
profile_id=RAMAN_PROFILE,
|
||||
)
|
||||
return {
|
||||
"rules_version": RULES_VERSION,
|
||||
"dossier": dossier,
|
||||
"raman_marriage_match": raman,
|
||||
}
|
||||
@@ -365,7 +365,7 @@ def _register_existing_techniques():
|
||||
module_path="transit.py", compute_func="computeTransit"),
|
||||
TechniqueSpec("synastry", "合盘系统", TechniqueCategory.SYNASTRY,
|
||||
TechniqueLevel.L2_STANDARD, TechniqueStatus.STABLE,
|
||||
module_path="synastry.py", compute_func="calc_synastry"),
|
||||
module_path="marriage_match.py", compute_func="build_marriage_match_dossier"),
|
||||
TechniqueSpec("prashna", "问事系统", TechniqueCategory.PRASHNA,
|
||||
TechniqueLevel.L2_STANDARD, TechniqueStatus.WIP,
|
||||
module_path="prashna_context.py", compute_func="build_prashna_context"),
|
||||
|
||||
Reference in New Issue
Block a user