"""Persisted replay controls and explicit, unit-aware demonstration rules.""" from datetime import datetime, timedelta from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator class ReplayModel(BaseModel): model_config = ConfigDict(extra="forbid", allow_inf_nan=False) class ScadaReplayRule(ReplayModel): rule_id: str = Field(min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$") device_ids: list[str] = Field(min_length=1, max_length=500) kind: Literal["relative_change", "bounds"] direction: Literal["both", "increase", "decrease"] = "both" threshold: float | None = Field(None, gt=0) lower: float | None = None upper: float | None = None unit: str = Field(min_length=1, max_length=40) metric_name: str = Field(default="", max_length=80) minimum_samples: int = Field(3, ge=1, le=96) window_samples: int = Field(4, ge=1, le=96) consecutive_samples: int = Field(1, ge=1, le=20) recovery_samples: int = Field(2, ge=1, le=20) minimum_devices: int = Field(1, ge=1, le=500) @model_validator(mode="after") def parameters(self): if len(set(self.device_ids)) != len(self.device_ids): raise ValueError("规则内设备不能重复") if self.minimum_devices > len(self.device_ids): raise ValueError("同时异常设备数超过规则设备数") if self.kind == "relative_change" and (self.threshold is None or self.minimum_samples > self.window_samples): raise ValueError("变化率规则需要阈值和足够的历史窗口") if self.kind == "bounds" and self.lower is None and self.upper is None: raise ValueError("上下限规则至少配置一个阈值") if self.lower is not None and self.upper is not None and self.lower >= self.upper: raise ValueError("规则下限必须小于上限") return self class ScadaQualityRange(ReplayModel): lower: float | None = None upper: float | None = None @model_validator(mode="after") def bounds(self): if self.lower is not None and self.upper is not None and self.lower >= self.upper: raise ValueError("仪表量程下限必须小于上限") return self class ScadaReplayCreate(ReplayModel): request_id: UUID start_time: datetime end_time: datetime device_ids: list[str] = Field(min_length=1, max_length=500) rules: list[ScadaReplayRule] = Field(min_length=1, max_length=30) quality_ranges: dict[str, ScadaQualityRange] = Field(default_factory=dict) @model_validator(mode="after") def configuration(self): if (self.start_time.tzinfo is None or self.end_time.tzinfo is None or not timedelta(0) < self.end_time - self.start_time <= timedelta(days=31)): raise ValueError("请选择带时区、最长 31 天的有效时间区间") ids = set(self.device_ids) if len(ids) != len(self.device_ids) or any(not value.strip() for value in ids): raise ValueError("设备标识不能为空或重复") if len({rule.rule_id for rule in self.rules}) != len(self.rules): raise ValueError("规则标识不能重复") if any(not set(rule.device_ids) <= ids for rule in self.rules) or not set(self.quality_ranges) <= ids: raise ValueError("规则及量程设备必须属于本次回放") return self class ScadaReplayCommand(ReplayModel): request_id: UUID expected_revision: int = Field(ge=1) action: Literal["step", "stop", "retry"] class ScadaMonitoringResult(ReplayModel): monitoring_id: UUID period_start: datetime period_end: datetime knowledge_cutoff: datetime batch_sequence: int = Field(ge=1) rule_version: str = Field(min_length=1, max_length=80) status: Literal["normal", "attention", "insufficient_data", "suspected_burst"] detail: str = Field(min_length=1, max_length=1000) task_id: UUID | None = None pressure: dict outlet_flow: dict burst_detection: dict role_gaps: list[str] = Field(default_factory=list) class ScadaReplaySnapshot(ReplayModel): replay_id: UUID project_id: UUID revision: int status: Literal["ready", "paused", "completed", "stopped", "failed"] start_time: datetime end_time: datetime monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000) step_pending: bool = False monitoring_count: int = 0 last_monitoring: ScadaMonitoringResult | None = None virtual_now: datetime delivered_until: datetime | None = None processed_until: datetime | None = None processed_count: int = 0 anomaly_count: int = 0 devices: list[dict] rules: list[ScadaReplayRule] latest: list[dict] active_events: list[dict] error: str | None = None created_at: datetime updated_at: datetime class ScadaReplayCapabilities(ReplayModel): available: bool can_write: bool reason: str | None = None monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000) burst_monitoring_ready: bool = False burst_monitoring_reason: str | None = None current: ScadaReplaySnapshot | None = None class ScadaReplayEventsPage(ReplayModel): items: list[dict] next_offset: int | None class ScadaReplayReadings(ReplayModel): items: list[dict] through: datetime