"""Publication-only validation for same-time-of-day line/scatter comparisons.""" from __future__ import annotations import re from collections.abc import Mapping from datetime import datetime, timedelta, timezone from typing import Any from app.domain.domain_errors import require DISPLAY_TIMEZONE = timezone(timedelta(hours=8)) DAY_SECONDS = 86400 def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None: """Validate new publications; persisted historical charts keep their read contract.""" chart_type = chart["chart_type"] series = chart["series"] kinds = {item.get("type") or chart_type for item in series} if chart_type != "time_comparison": require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID", "曲线与历史散点复合图须使用 chart_type=time_comparison", status=422) return def check(condition: bool, message: str) -> None: require(condition, "TIME_COMPARISON_INVALID", message, status=422) kinds = {item.get("type") or "line" for item in series} check(kinds == {"line", "scatter"}, "time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar") labels = chart["x_data"] check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label) for label in labels), "time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴") check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度") seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1))) for label in labels] offsets = [0] for index in range(1, len(seconds)): step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位") offsets.append(offsets[-1] + step) check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位") current_bases: set[datetime] = set() historical_bases: list[datetime] = [] for series_index, item in enumerate(series): times = item.get("sample_times") path = f"series[{series_index}]" check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长") bases: set[datetime] = set() for index, (value, at) in enumerate(zip(item["data"], times, strict=True)): check((value is None) == (at is None), f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null") if at is None: continue try: local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE) base = local - timedelta(seconds=offsets[index]) except OverflowError: check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围") label = labels[index] expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M") check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0), f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐") bases.add(base) check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组") if (item.get("type") or "line") == "line": current_bases.update(bases) else: historical_bases.extend(bases) check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口") current = next(iter(current_bases)) for historical in historical_bases: delta = (current - historical).total_seconds() check(delta > 0 and delta % DAY_SECONDS == 0, "历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")