feat(scada): add project monitoring replay
This commit is contained in:
@@ -2,9 +2,10 @@ from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
||||
from app.domain.schemas.project_settings import ProjectSettings
|
||||
|
||||
|
||||
BusinessRole = Literal["admin", "service", "user"]
|
||||
@@ -72,6 +73,7 @@ class ProjectMemberResponse(BaseModel):
|
||||
|
||||
|
||||
class AdminProjectCreateRequest(BaseModel):
|
||||
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
@@ -82,6 +84,10 @@ class AdminProjectCreateRequest(BaseModel):
|
||||
|
||||
|
||||
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||
settings: ProjectSettings | None = Field(
|
||||
default=None,
|
||||
description="按顶层键合并项目配置;省略的键保持原值,嵌套对象整体替换",
|
||||
)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
@@ -89,8 +95,16 @@ class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||
map_extent: dict | None = None
|
||||
status: ProjectStatus | None = None
|
||||
|
||||
@field_validator("settings")
|
||||
@classmethod
|
||||
def reject_null_settings(cls, value: ProjectSettings | None) -> ProjectSettings:
|
||||
if value is None:
|
||||
raise ValueError("settings must be an object")
|
||||
return value
|
||||
|
||||
|
||||
class AdminProjectResponse(BaseModel):
|
||||
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||
project_id: UUID
|
||||
name: str
|
||||
code: str
|
||||
|
||||
@@ -4,9 +4,11 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||
from app.domain.schemas.project_settings import ProjectSettings
|
||||
|
||||
|
||||
class ProjectMetaResponse(BaseModel):
|
||||
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||
project_id: UUID
|
||||
name: str
|
||||
code: str
|
||||
|
||||
@@ -41,12 +41,19 @@ ExecutionStatus = Literal[
|
||||
]
|
||||
|
||||
|
||||
class ScadaReplayTaskContext(StrictModel):
|
||||
replay_id: UUID
|
||||
knowledge_cutoff: WorkbenchDateTime
|
||||
batch_sequence: PositiveInt
|
||||
|
||||
|
||||
class TaskSource(StrictModel):
|
||||
kind: TaskSourceKind
|
||||
external_id: NonEmptyStr | None = None
|
||||
external_revision: PositiveInt | None = None
|
||||
event_kind: NonEmptyStr | None = None
|
||||
deleted_at: WorkbenchDateTime | None = None
|
||||
replay_context: ScadaReplayTaskContext | None = None
|
||||
|
||||
|
||||
class TaskTrigger(StrictModel):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
|
||||
class ScadaMonitoringSettings(BaseModel):
|
||||
"""Project-owned device roles used by the manual SCADA monitoring demo."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
rule_version: str = Field(
|
||||
default="burst-monitor-v1",
|
||||
min_length=1,
|
||||
max_length=80,
|
||||
pattern=r"^[a-zA-Z0-9_.-]+$",
|
||||
)
|
||||
pressure_device_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||
outlet_flow_device_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def distinct_roles(self):
|
||||
for values in (self.pressure_device_ids, self.outlet_flow_device_ids):
|
||||
if len(set(values)) != len(values) or any(not value.strip() for value in values):
|
||||
raise ValueError("SCADA 监测角色中的设备标识不能为空或重复")
|
||||
if set(self.pressure_device_ids) & set(self.outlet_flow_device_ids):
|
||||
raise ValueError("同一设备不能同时作为压力与出厂边界流量")
|
||||
return self
|
||||
|
||||
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Extensible, non-secret project parameters stored in MetaDB as JSON."""
|
||||
|
||||
model_config = ConfigDict(extra="allow", allow_inf_nan=False)
|
||||
|
||||
__pydantic_extra__: dict[str, JsonValue] = Field(init=False)
|
||||
scada_monitoring_interval_seconds: int | None = Field(
|
||||
default=None,
|
||||
strict=True,
|
||||
ge=60,
|
||||
le=31_536_000,
|
||||
description="SCADA 主动监测演示步长,单位秒;null 表示未配置",
|
||||
)
|
||||
scada_monitoring: ScadaMonitoringSettings | None = Field(
|
||||
default=None,
|
||||
description="主动监测使用的项目设备角色;不创建定时任务",
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ScadaDeviceResponse(BaseModel):
|
||||
"""Project SCADA metadata keyed by the canonical device identifier."""
|
||||
|
||||
@@ -47,3 +47,20 @@ class ScadaLatestReadingsResponse(BaseModel):
|
||||
offset: int = Field(ge=0)
|
||||
observed_count: int = Field(ge=0)
|
||||
latest_observed_at: datetime | None = None
|
||||
|
||||
|
||||
class ScadaRawReading(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
time: datetime
|
||||
device_id: str
|
||||
monitored_value: float | None
|
||||
quality: Literal["unvalidated", "missing", "invalid"] = "unvalidated"
|
||||
|
||||
|
||||
class ScadaRawPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
items: list[ScadaRawReading]
|
||||
next_cursor: str | None
|
||||
scanned_through: datetime
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""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
|
||||
@@ -5,7 +5,7 @@ from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchScadaReplaySource, WorkbenchDateTime
|
||||
|
||||
|
||||
class TaskAlertCommand(StrictModel):
|
||||
@@ -14,7 +14,7 @@ class TaskAlertCommand(StrictModel):
|
||||
event_type: Literal["raised", "acknowledged", "resolved"]
|
||||
occurred_at: WorkbenchDateTime
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
severity: Literal["warning", "critical"]
|
||||
@@ -29,7 +29,7 @@ class TaskAlertState(StrictModel):
|
||||
revision: PositiveInt
|
||||
producer_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
|
||||
@@ -49,10 +49,17 @@ class WorkbenchAlertSource(StrictModel):
|
||||
run_id: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchScadaReplaySource(StrictModel):
|
||||
kind: Literal["scada_replay"]
|
||||
replay_id: NonEmptyStr
|
||||
rule_id: NonEmptyStr
|
||||
batch_sequence: int = Field(ge=1)
|
||||
|
||||
|
||||
class WorkbenchRuntimeAlert(StrictModel):
|
||||
id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
@@ -62,7 +69,7 @@ class WorkbenchRuntimeAlert(StrictModel):
|
||||
class WorkbenchPersistedAlert(StrictModel):
|
||||
alert_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
|
||||
Reference in New Issue
Block a user