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,
|
||||
}
|
||||
Reference in New Issue
Block a user