feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+15 -1
View File
@@ -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
+2
View File
@@ -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
+7
View File
@@ -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):
+44
View File
@@ -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="主动监测使用的项目设备角色;不创建定时任务",
)
+18 -1
View File
@@ -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
+142
View File
@@ -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
+3 -3
View File
@@ -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
+9 -2
View File
@@ -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