Files
Jyotisha/scripts/flexible_birth_time_profile.py
T
2026-09-04 02:11:57 +08:00

324 lines
13 KiB
Python

"""Build a read-only sensitivity profile for an unresolved birth-time window."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
from hashlib import sha256
import json
import re
from typing import Any, Mapping, Sequence
SCHEMA_VERSION = "jyotish.flexible_birth_time_profile.v1"
MAX_CANDIDATE_MINUTES = 15
_CLOCK_RE = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
_PROHIBITED_AUTHORITY_FIELDS = frozenset({
"approved_birth_time",
"final_birth_time",
"winner",
"approval",
"approval_authority",
})
_PROHIBITED_SOURCE_STATUSES = frozenset({"approved", "confirmed"})
class FlexibleBirthTimeProfileError(ValueError):
"""Raised when a candidate window cannot be represented safely."""
def build_flexible_birth_time_profile(
candidates: Sequence[Mapping[str, Any]],
*,
source_reference: Mapping[str, Any],
) -> dict[str, Any]:
_reject_candidate_window_authority(candidates)
rows = _normalize_candidates(candidates)
source = _normalize_source_reference(source_reference)
stable: dict[str, Any] = {}
sensitive: dict[str, dict[str, Any]] = {}
for key in sorted({key for row in rows for key in row["evidence"]}):
values = {row["candidate_time"]: row["evidence"].get(key) for row in rows}
if len({_canonical(value) for value in values.values()}) == 1:
stable[key] = deepcopy(next(iter(values.values())))
else:
sensitive[key] = deepcopy(values)
times = [row["candidate_time"] for row in rows]
profile_id = _profile_id(times, source["review_id"])
return {
"schema_version": SCHEMA_VERSION,
"flexible_profile_id": profile_id,
"birth_time_window": {
"start_time": times[0],
"end_time": times[-1],
"candidate_count": len(times),
"candidate_times": times,
},
"candidate_references": [
{"candidate_id": row["candidate_id"], "candidate_time": row["candidate_time"]}
for row in rows
],
"stable_evidence": stable,
"sensitive_evidence": sensitive,
"source_reference": source,
"trace": [
{"kind": "flexible_birth_time_profile", "reference": profile_id},
{"kind": "candidate_window", "reference": source["review_id"]},
*(
{"kind": "candidate", "reference": f"candidate://{row['candidate_id']}"}
for row in rows
),
],
"status": "candidate_window_only",
"claim_boundary": (
"Read-only candidate-window comparison. It cannot select or confirm a birth minute, "
"replace chart identity, or grant authority to a candidate chart."
),
}
def build_flexible_birth_time_profile_from_window(
*,
birth_date: str,
start_time: str,
end_time: str,
candidate_times: Sequence[str],
lat: float,
lon: float,
tz: float,
ayanamsa: str = "raman",
node_mode: str = "mean",
source_reference: Mapping[str, Any],
) -> dict[str, Any]:
start, end = _parse_window(birth_date, start_time, end_time)
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
candidates = [
_candidate_from_recast(
candidate_at=value,
recast=_recast_candidate_layers(
value, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
),
ayanamsa=ayanamsa,
node_mode=node_mode,
)
for value in normalized_times
]
profile = build_flexible_birth_time_profile(candidates, source_reference=source_reference)
profile["calculation_profile"] = {
"ayanamsa": ayanamsa,
"node_mode": node_mode,
"coordinate_mode": "explicit_lat_lon_tz",
"candidate_recast": "native_domain_calculation_service",
}
return profile
def _recast_candidate_layers(
candidate: datetime,
*,
lat: float,
lon: float,
tz: float,
ayanamsa: str,
node_mode: str,
) -> dict[str, Any]:
try:
import domain_calculation_service
import jaimini
import kp_system
import varga
except ModuleNotFoundError: # pragma: no cover - package import
from scripts import domain_calculation_service, jaimini, kp_system, varga
chart = domain_calculation_service.compute_chart({
"year": candidate.year,
"month": candidate.month,
"day": candidate.day,
"hour": candidate.hour,
"minute": candidate.minute,
"second": candidate.second,
"lat": lat,
"lon": lon,
"tz": tz,
"ayanamsa": ayanamsa,
"node_mode": node_mode,
})
planets = {
name: row["lon"]
for name, row in (chart.get("planets") or {}).items()
if name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"}
}
ascendant = chart.get("ascendant") or {}
asc_lon = float(ascendant["lon"])
divisions = [2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 16, 24, 30, 40, 45, 60]
vargas = varga.calc_all_vargas(planets, asc_lon, divisions=divisions)
arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planets)
padas = arudha.get("padas") or {}
kp_cusps: dict[str, Any] = {}
for house_key in ("house_1", "house_4", "house_7", "house_10"):
degree = ((chart.get("houses") or {}).get(house_key) or {}).get("cusp_degree")
if degree is None:
continue
lords = kp_system.get_kp_lords(float(degree))
kp_cusps[house_key] = {
"sign": lords.get("sign"),
"nakshatra_lord": lords.get("nakshatra_lord"),
"sub_lord": lords.get("sub_lord"),
}
return {
"ascendant": ascendant,
"varga_lagna": {key: value.get("Ascendant") or {} for key, value in vargas.items()},
"arudha": {"A7": padas.get("A7") or {}, "A10": padas.get("A10") or {}, "UL": arudha.get("upapada") or {}},
"kp_cusps": kp_cusps,
}
def _candidate_from_recast(
*,
candidate_at: datetime,
recast: Mapping[str, Any],
ayanamsa: str,
node_mode: str,
) -> dict[str, Any]:
evidence: dict[str, Any] = {"D1.ascendant": (recast.get("ascendant") or {}).get("sign")}
for key, value in (recast.get("varga_lagna") or {}).items():
if isinstance(key, str) and key.startswith("D") and isinstance(value, Mapping):
evidence[f"{key.split('_', 1)[0]}.ascendant"] = value.get("sign")
for key in ("A7", "A10", "UL"):
value = (recast.get("arudha") or {}).get(key)
if isinstance(value, Mapping):
evidence[f"arudha.{key}"] = value.get("sign")
evidence["KP.cusp_observation"] = recast.get("kp_cusps") or {}
candidate_time = candidate_at.strftime("%H:%M")
candidate_id = f"candidate-{candidate_time.replace(':', '')}"
return {
"candidate_id": candidate_id,
"candidate_time": candidate_time,
"evidence": evidence,
"trace": [
{"kind": "candidate_chart_recast", "reference": f"candidate-chart://{candidate_id}"},
{"kind": "calculation_profile", "reference": f"ayanamsa://{ayanamsa}/node/{node_mode}"},
],
}
def _parse_window(birth_date: str, start_time: str, end_time: str) -> tuple[datetime, datetime]:
if not _is_hh_mm(start_time) or not _is_hh_mm(end_time):
raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid")
try:
start = datetime.strptime(f"{birth_date} {start_time}", "%Y-%m-%d %H:%M")
end = datetime.strptime(f"{birth_date} {end_time}", "%Y-%m-%d %H:%M")
except (TypeError, ValueError) as exc:
raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid") from exc
if end < start:
raise FlexibleBirthTimeProfileError("candidate_window_must_not_cross_midnight")
return start, end
def _normalize_candidate_times(
birth_date: str,
start: datetime,
end: datetime,
candidate_times: Sequence[str],
) -> list[datetime]:
if isinstance(candidate_times, (str, bytes)) or not isinstance(candidate_times, Sequence):
raise FlexibleBirthTimeProfileError("candidate_times_required")
if len(candidate_times) < 2 or len(candidate_times) > MAX_CANDIDATE_MINUTES:
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_fifteen")
values: list[datetime] = []
for raw in candidate_times:
if not _is_hh_mm(raw):
raise FlexibleBirthTimeProfileError("candidate_time_invalid")
try:
value = datetime.strptime(f"{birth_date} {raw}", "%Y-%m-%d %H:%M")
except (TypeError, ValueError) as exc:
raise FlexibleBirthTimeProfileError("candidate_time_invalid") from exc
if value < start or value > end:
raise FlexibleBirthTimeProfileError("candidate_time_outside_window")
values.append(value)
if len(set(values)) != len(values):
raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique")
return sorted(values)
def _normalize_candidates(candidates: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
if isinstance(candidates, (str, bytes)) or not isinstance(candidates, Sequence):
raise FlexibleBirthTimeProfileError("candidates_required")
if len(candidates) < 2 or len(candidates) > MAX_CANDIDATE_MINUTES:
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_fifteen")
rows: list[dict[str, Any]] = []
for candidate in candidates:
if not isinstance(candidate, Mapping):
raise FlexibleBirthTimeProfileError("candidate_must_be_mapping")
candidate_id = candidate.get("candidate_id")
candidate_time = candidate.get("candidate_time")
evidence = candidate.get("evidence")
trace = candidate.get("trace")
if not isinstance(candidate_id, str) or not candidate_id:
raise FlexibleBirthTimeProfileError("candidate_id_required")
if not _is_hh_mm(candidate_time):
raise FlexibleBirthTimeProfileError("candidate_time_required")
if not isinstance(evidence, Mapping) or not evidence:
raise FlexibleBirthTimeProfileError("candidate_evidence_required")
if not isinstance(trace, list) or not trace:
raise FlexibleBirthTimeProfileError("candidate_trace_required")
rows.append({"candidate_id": candidate_id, "candidate_time": candidate_time, "evidence": dict(evidence), "trace": trace})
if len({row["candidate_id"] for row in rows}) != len(rows):
raise FlexibleBirthTimeProfileError("candidate_ids_must_be_unique")
if len({row["candidate_time"] for row in rows}) != len(rows):
raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique")
return sorted(rows, key=lambda row: row["candidate_time"])
def _normalize_source_reference(value: Mapping[str, Any]) -> dict[str, str]:
if not isinstance(value, Mapping):
raise FlexibleBirthTimeProfileError("source_reference_must_be_mapping")
_reject_candidate_window_authority(value)
status = value.get("status")
if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES:
raise FlexibleBirthTimeProfileError("approved_or_confirmed_source_reference_forbidden")
review_id = value.get("review_id")
if not isinstance(review_id, str) or not review_id:
raise FlexibleBirthTimeProfileError("source_review_id_required")
return {"review_id": review_id, "status": "review_required"}
def _profile_id(candidate_times: Sequence[str], review_id: str) -> str:
digest = sha256(repr((tuple(candidate_times), review_id)).encode("utf-8")).hexdigest()[:24]
return f"flexible-birth-time://{digest}"
def _canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
def _is_hh_mm(value: Any) -> bool:
return isinstance(value, str) and _CLOCK_RE.fullmatch(value) is not None
def _candidate_window_authority_violation(value: Any, path: str = "$") -> str | None:
if isinstance(value, Mapping):
for raw_key, item in value.items():
key = str(raw_key).strip().lower()
child_path = f"{path}.{raw_key}"
if key in _PROHIBITED_AUTHORITY_FIELDS:
return child_path
if key == "source_reference" and isinstance(item, Mapping):
status = item.get("status")
if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES:
return f"{child_path}.status"
violation = _candidate_window_authority_violation(item, child_path)
if violation:
return violation
elif isinstance(value, (list, tuple)):
for index, item in enumerate(value):
violation = _candidate_window_authority_violation(item, f"{path}[{index}]")
if violation:
return violation
return None
def _reject_candidate_window_authority(value: Any) -> None:
violation = _candidate_window_authority_violation(value)
if violation:
raise FlexibleBirthTimeProfileError(f"candidate_window_authority_forbidden:{violation}")