143 lines
5.3 KiB
Python
143 lines
5.3 KiB
Python
"""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
|