feat(rectification): open on the nearest chart boundary (BUG-1307)

This commit is contained in:
jesse-ux
2026-10-12 15:36:43 +08:00
committed by Jesse_Chen
parent 805932addd
commit 74949d0475
22 changed files with 1066 additions and 30 deletions
+111
View File
@@ -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,
}