feat(rectification): open on the nearest chart boundary (BUG-1307)
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""Nearest divisional-chart change around a recorded minute.
|
||||
|
||||
Reads ``window_scan`` transitions only. Does not score and does not change
|
||||
the scan payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Same domain map as event_probes.LAYER_DOMAIN. Topics are the opening words.
|
||||
CHART_LAYERS = ("d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1")
|
||||
LAYER_DOMAIN = {
|
||||
"d9": "relationship",
|
||||
"d10": "career",
|
||||
"d4": "relocation",
|
||||
"d5": "education",
|
||||
"d24": "education",
|
||||
"d7": "family",
|
||||
"d12": "family",
|
||||
"d2": "finance",
|
||||
"d11": "finance",
|
||||
"d30": "health_pressure",
|
||||
}
|
||||
LAYER_TOPIC = {
|
||||
"d9": "婚恋",
|
||||
"d10": "工作",
|
||||
"d4": "住处",
|
||||
"d5": "学业",
|
||||
"d24": "学业",
|
||||
"d7": "子女",
|
||||
"d12": "父母",
|
||||
"d2": "钱",
|
||||
"d11": "钱",
|
||||
"d30": "身体",
|
||||
}
|
||||
|
||||
|
||||
def _clock(value: Any) -> int | None:
|
||||
raw = str(value or "")[:5]
|
||||
if len(raw) != 5 or raw[2] != ":" or not (raw[:2].isdigit() and raw[3:].isdigit()):
|
||||
return None
|
||||
hour, minute = int(raw[:2]), int(raw[3:])
|
||||
if hour > 23 or minute > 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def _distance(left: int, right: int) -> int:
|
||||
span = abs(left - right)
|
||||
return min(span, 1440 - span)
|
||||
|
||||
|
||||
def nearest_chart_boundary(scan: dict[str, Any] | None, recorded_time: str | None) -> dict[str, Any]:
|
||||
"""Closest real sign change to ``recorded_time``.
|
||||
|
||||
Display-only layers and segment-start rows that do not change sign are
|
||||
ignored. An equal distance keeps the earlier clock.
|
||||
"""
|
||||
recorded = _clock(recorded_time)
|
||||
rows = scan.get("transitions") if isinstance(scan, dict) else None
|
||||
if recorded is None or not isinstance(rows, list):
|
||||
return {"found": False, "reason": "unavailable", "layers": [], "topics": [], "domains": []}
|
||||
changes: list[tuple[int, str, str, str]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
layer = str(row.get("layer") or "")
|
||||
if layer not in CHART_LAYERS:
|
||||
continue
|
||||
before, after = row.get("from_sign"), row.get("to_sign")
|
||||
if not isinstance(before, str) or not isinstance(after, str) or before == after:
|
||||
continue
|
||||
at = _clock(row.get("at"))
|
||||
if at is None:
|
||||
continue
|
||||
changes.append((at, layer, before, after))
|
||||
if not changes:
|
||||
return {"found": False, "reason": "no_transition", "layers": [], "topics": [], "domains": []}
|
||||
best_at = min(changes, key=lambda item: (_distance(item[0], recorded), item[0]))[0]
|
||||
order = {layer: index for index, layer in enumerate(CHART_LAYERS)}
|
||||
layers: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for at, layer, before, after in sorted(changes, key=lambda item: order[item[1]]):
|
||||
if at != best_at or layer in seen:
|
||||
continue
|
||||
seen.add(layer)
|
||||
layers.append({
|
||||
"layer": layer,
|
||||
"from_sign": before,
|
||||
"to_sign": after,
|
||||
"topic": LAYER_TOPIC.get(layer),
|
||||
"domain": LAYER_DOMAIN.get(layer),
|
||||
})
|
||||
topics: list[str] = []
|
||||
domains: list[str] = []
|
||||
for row in layers:
|
||||
topic, domain = row["topic"], row["domain"]
|
||||
if isinstance(topic, str) and topic not in topics:
|
||||
topics.append(topic)
|
||||
if isinstance(domain, str) and domain not in domains:
|
||||
domains.append(domain)
|
||||
return {
|
||||
"found": True,
|
||||
"reason": None,
|
||||
"at": f"{best_at // 60:02d}:{best_at % 60:02d}",
|
||||
"distance_minutes": _distance(best_at, recorded),
|
||||
"layers": layers,
|
||||
"topics": topics,
|
||||
"domains": domains,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from scripts import domain_calculation_service, varga
|
||||
|
||||
TARGETS = ("D1", "D9", "D10")
|
||||
BOUNDARY_CHARTS = ("D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30")
|
||||
VERSION = "rectification-varga-segments-v1"
|
||||
|
||||
|
||||
@@ -45,12 +46,16 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
or any(not isinstance(target, str) or target not in TARGETS for target in targets)
|
||||
or len(set(targets)) != len(targets)):
|
||||
raise ValueError("invalid_target_charts")
|
||||
boundary_charts, recorded_time = _boundary_request(body)
|
||||
profile = {
|
||||
"lat": _number(body, "latitude", -90, 90),
|
||||
"lon": _number(body, "longitude", -180, 180),
|
||||
"tz": _number(body, "timezone_offset", -14, 14),
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
}
|
||||
if boundary_charts:
|
||||
profile["boundary_charts"] = list(boundary_charts)
|
||||
profile["recorded_time"] = recorded_time
|
||||
key = hashlib.sha256(json.dumps({"version": VERSION, "start": str(start), "end": str(end), "targets": targets, **profile}, sort_keys=True).encode()).hexdigest()
|
||||
root = cache_dir if cache_dir is not None else Path(os.environ.get("JYOTISH_CACHE_DIR", str(Path(__file__).resolve().parents[1] / "scratch" / "local"))) / "varga_segments"
|
||||
path = root / f"{key}.json"
|
||||
@@ -63,22 +68,34 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
pass
|
||||
minutes = []
|
||||
segments = {target: [] for target in targets}
|
||||
boundary_signs: list[dict[str, int]] = []
|
||||
wanted = list(dict.fromkeys([*targets, *boundary_charts]))
|
||||
divisions = [int(chart[1:]) for chart in wanted if chart not in {"D1", "D11"}]
|
||||
d11_chart = None
|
||||
if "D11" in wanted:
|
||||
from scripts.active_rectification_event_engine import _d11_chart as d11_chart
|
||||
for offset in range(width):
|
||||
moment = start + timedelta(minutes=offset)
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
**profile, "year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"lat": profile["lat"], "lon": profile["lon"], "tz": profile["tz"],
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
"year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"hour": moment.hour, "minute": moment.minute,
|
||||
})
|
||||
ascendant = float(chart["ascendant"]["lon"])
|
||||
signs = {"D1": int(ascendant // 30) % 12} if "D1" in targets else {}
|
||||
divisions = [int(target[1:]) for target in targets if target != "D1"]
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
all_signs = {"D1": int(ascendant // 30) % 12} if "D1" in wanted else {}
|
||||
if divisions:
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
computed = varga.calc_all_vargas(planets, ascendant, divisions=divisions)
|
||||
for value in computed.values():
|
||||
signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
all_signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
if "D11" in wanted:
|
||||
all_signs["D11"] = int(d11_chart(planets, ascendant)["Ascendant"]["sign_idx"])
|
||||
signs = {target: all_signs[target] for target in targets}
|
||||
stamp = moment.strftime("%Y-%m-%dT%H:%M")
|
||||
minutes.append({"offset": offset, "time": stamp[11:], "date": stamp[:10], "signs": signs})
|
||||
if boundary_charts:
|
||||
boundary_signs.append({chart: all_signs[chart] for chart in boundary_charts if chart in all_signs})
|
||||
for target in targets:
|
||||
rows = segments[target]
|
||||
sign = signs[target]
|
||||
@@ -90,6 +107,8 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
"window_minutes": width, "target_charts": targets, "minutes": minutes,
|
||||
"segments": segments, "full_window_unique": {target: len({row['sign'] for row in rows}) == 1 for target, rows in segments.items()},
|
||||
"ayanamsa": "raman", "node_mode": "mean", "step_minutes": 1}
|
||||
if boundary_charts:
|
||||
payload["nearest_boundary"] = _nearest_from_signs(boundary_signs, [row["time"] for row in minutes], recorded_time)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=root, delete=False) as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
||||
@@ -101,6 +120,47 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
return payload
|
||||
|
||||
|
||||
def _boundary_request(body: dict[str, Any]) -> tuple[tuple[str, ...], str | None]:
|
||||
if "boundary_charts" not in body:
|
||||
return (), None
|
||||
charts = body.get("boundary_charts")
|
||||
if (not isinstance(charts, list) or not charts
|
||||
or any(not isinstance(chart, str) or chart not in BOUNDARY_CHARTS for chart in charts)
|
||||
or len(set(charts)) != len(charts)):
|
||||
raise ValueError("invalid_boundary_charts")
|
||||
recorded = body.get("recorded_time")
|
||||
if not isinstance(recorded, str) or len(recorded) < 5 or recorded[2] != ":":
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour, minute = recorded[:2], recorded[3:5]
|
||||
if not (hour.isdigit() and minute.isdigit()):
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour_value, minute_value = int(hour), int(minute)
|
||||
if hour_value > 23 or minute_value > 59:
|
||||
raise ValueError("invalid_recorded_time")
|
||||
return tuple(charts), f"{hour_value:02d}:{minute_value:02d}"
|
||||
|
||||
|
||||
def _nearest_from_signs(signs_by_minute: list[dict[str, int]], times: list[str], recorded_time: str | None) -> dict[str, Any]:
|
||||
from scripts.jyotish_engine import SIGNS, SIGNS_CN
|
||||
from scripts.rectification.nearest_boundary import nearest_chart_boundary
|
||||
|
||||
transitions: list[dict[str, str]] = []
|
||||
previous: dict[str, int] | None = None
|
||||
for signs, clock in zip(signs_by_minute, times):
|
||||
if previous is not None:
|
||||
for chart, sign in signs.items():
|
||||
before = previous.get(chart)
|
||||
if isinstance(before, int) and isinstance(sign, int) and before != sign and 0 <= before <= 11 and 0 <= sign <= 11:
|
||||
transitions.append({
|
||||
"layer": chart.lower(),
|
||||
"at": clock,
|
||||
"from_sign": SIGNS_CN[SIGNS[before]],
|
||||
"to_sign": SIGNS_CN[SIGNS[sign]],
|
||||
})
|
||||
previous = signs
|
||||
return nearest_chart_boundary({"transitions": transitions}, recorded_time)
|
||||
|
||||
|
||||
def handle_varga_segments(handler, body):
|
||||
from scripts.offline_compute_mixins import BadRequest
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user