Prompt-only projection rules allowed historical samples to be appended to the current axis. Enforce typed comparison semantics at presentation and report publication, preserving historical read contracts and returning actionable validation errors.
79 lines
4.1 KiB
Python
79 lines
4.1 KiB
Python
"""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 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
|