Files
next-tjwater-server/app/domain/schemas/scada_replay.py
T
jiang 26fa5d1909
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s
feat(scada): add project monitoring replay
2026-11-04 14:27:21 +08:00

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